mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
Edit a PDF: select all, copy, cut and paste page objects within and between documents (#6639)
Refs #5989. Ctrl/Cmd+A selects every object on the page; copied objects paste as page content that draws the same, within a document or into another, with organize's bounded deep copy across documents. Headless: object_copy, object_paste, object_move all.
This commit is contained in:
1 parent
c07e092ff5
commit
e0f90d5f2d
34 files changed
+1664
-103
No files matched your search
@@ -146,7 +146,10 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root <dir>
|
||||
`object_list {doc, page}` inventories recognised paragraphs, Image/Form artwork and added items
|
||||
with generation-scoped kind/index references. `object_move {doc, page, objects, offset, generation?}`
|
||||
moves 1–1000 references atomically in displayed points (right/down), preserving source glyph bytes
|
||||
and fonts. A refusal changes nothing; success is one undo step. See
|
||||
and fonts. A refusal changes nothing; success is one undo step. `all: true` takes every object on the
|
||||
page instead, as ⌘A does. `object_copy {doc, page, objects | all, cut?}` copies them with every
|
||||
resource they use and `object_paste {doc, page}` pastes them as page content that draws the same,
|
||||
into the same or another open document, stepped as the app steps a paste. See
|
||||
[the selection and movement guide](../../docs/editing-multiple-objects.md) for limits and examples.
|
||||
|
||||
## Accessibility review record
|
||||
|
||||
@@ -354,14 +354,32 @@ impl Automation {
|
||||
Ok(json!({"page":page+1,"generation":doc.edit_generation(),"count":items.len(),"objects":items}))
|
||||
}
|
||||
|
||||
pub(crate) fn object_move(&mut self, a: &Args) -> Result<Value> {
|
||||
/// The references `objects` names (from object_list), or with `all` every object on `page`,
|
||||
/// as ⌘A selects them in Edit a PDF; refused when `generation` is not the document's.
|
||||
fn object_targets(&self, a: &Args, page: usize) -> Result<Vec<pdfcraft_engine::ObjectTarget>> {
|
||||
use pdfcraft_engine::{MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
|
||||
let page = self.page(a)?;
|
||||
let doc = self.doc(a)?;
|
||||
if let Some(g) = a.get("generation") {
|
||||
let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?;
|
||||
if g != doc.edit_generation() {
|
||||
return Err(failed("the document changed; call object_list again"));
|
||||
}
|
||||
}
|
||||
if a.opt_bool("all")?.unwrap_or(false) {
|
||||
if a.get("objects").is_some() {
|
||||
return Err(bad("give objects or all, not both"));
|
||||
}
|
||||
let all: Vec<_> = doc.editable_objects(page).map_err(failed)?.into_iter().map(|o| o.target).collect();
|
||||
if all.is_empty() || all.len() > MAX_MOVE_OBJECTS {
|
||||
return Err(failed(format!("page {} has {} objects; select 1–1000", page + 1, all.len())));
|
||||
}
|
||||
return Ok(all);
|
||||
}
|
||||
let rows = a
|
||||
.get("objects")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|r| !r.is_empty() && r.len() <= MAX_MOVE_OBJECTS)
|
||||
.ok_or_else(|| bad("objects must contain 1–1000 references from object_list"))?;
|
||||
.ok_or_else(|| bad("objects must contain 1–1000 references from object_list (or pass all: true)"))?;
|
||||
let mut targets = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let row = row.as_object().ok_or_else(|| bad("each object must contain kind and index"))?;
|
||||
@@ -382,6 +400,12 @@ impl Automation {
|
||||
.ok_or_else(|| bad("object index must be a positive integer from object_list"))?;
|
||||
targets.push(ObjectTarget { kind, index });
|
||||
}
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
pub(crate) fn object_move(&mut self, a: &Args) -> Result<Value> {
|
||||
let page = self.page(a)?;
|
||||
let targets = self.object_targets(a, page)?;
|
||||
let delta = a
|
||||
.get("offset")
|
||||
.and_then(Value::as_array)
|
||||
@@ -389,17 +413,46 @@ impl Automation {
|
||||
.and_then(|v| Some([v.first()?.as_f64()?, v.get(1)?.as_f64()?]))
|
||||
.filter(|v| v.iter().all(|n| n.is_finite() && n.abs() <= 1e9))
|
||||
.ok_or_else(|| bad("offset must contain two finite numbers [dx, dy] in displayed points"))?;
|
||||
let doc = self.doc(a)?;
|
||||
if let Some(g) = a.get("generation") {
|
||||
let g = g.as_u64().ok_or_else(|| bad("generation must be a non-negative integer from object_list"))?;
|
||||
if g != doc.edit_generation() {
|
||||
return Err(failed("the document changed; call object_list again before moving"));
|
||||
}
|
||||
}
|
||||
let offset = doc.object_move_offset(page, delta).map_err(failed)?;
|
||||
let offset = self.doc(a)?.object_move_offset(page, delta).map_err(failed)?;
|
||||
let count = targets.len();
|
||||
let mut result = self.apply(a, Edit::MoveObjects { page, objects: targets, offset })?;
|
||||
result["moved"] = json!(count);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Edit ▸ Copy (or with `cut`, Cut) of objects selected together, kept for object_paste into
|
||||
/// any open document. A cut then deletes them as one undo step.
|
||||
pub(crate) fn object_copy(&mut self, a: &Args) -> Result<Value> {
|
||||
let page = self.page(a)?;
|
||||
let targets = self.object_targets(a, page)?;
|
||||
let cut = a.opt_bool("cut")?.unwrap_or(false);
|
||||
let doc = self.doc(a)?;
|
||||
if cut && !doc.allows_modification() {
|
||||
return Err(failed(pdfcraft_engine::EditError::NotPermitted("changes")));
|
||||
}
|
||||
let clip = doc.copy_objects(page, &targets).map_err(failed)?;
|
||||
let out = json!({ "copied": clip.objects().len(), "text": clip.text() });
|
||||
self.objects_clipboard = Some(pdfcraft_engine::ObjectClipboard::new(clip));
|
||||
if !cut {
|
||||
return Ok(out);
|
||||
}
|
||||
let delete = Edit::delete_objects(page, targets).ok_or_else(|| failed("nothing to cut"))?;
|
||||
let mut result = self.apply(a, delete)?;
|
||||
result["copied"] = out["copied"].clone();
|
||||
result["text"] = out["text"].clone();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste of the objects object_copy copied: onto `page`, where they were, or on the
|
||||
/// page they came from (and on each further paste onto a page) 18 points down and right.
|
||||
pub(crate) fn object_paste(&mut self, a: &Args) -> Result<Value> {
|
||||
let page = self.page(a)?;
|
||||
let mut clipboard = self.objects_clipboard.take().ok_or_else(|| failed("copy objects first (object_copy)"))?;
|
||||
let count = clipboard.clip().objects().len();
|
||||
let edit = self.doc(a).and_then(|doc| clipboard.paste(doc, page).map_err(failed));
|
||||
self.objects_clipboard = Some(clipboard);
|
||||
let mut result = self.apply(a, edit?)?;
|
||||
result["pasted"] = json!(count);
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -738,6 +738,8 @@ impl Automation {
|
||||
"links_remove" => self.links_remove(&a)?,
|
||||
"object_list" => self.object_list(&a)?,
|
||||
"object_move" => self.object_move(&a)?,
|
||||
"object_copy" => self.object_copy(&a)?,
|
||||
"object_paste" => self.object_paste(&a)?,
|
||||
"content_list" => self.content_list(&a)?,
|
||||
"page_add_text" => self.page_add_text(&a)?,
|
||||
"page_add_image" => self.page_add_image(&a)?,
|
||||
|
||||
@@ -101,6 +101,8 @@ pub struct Automation {
|
||||
/// Review sessions held for `ocr_words` (a guard and the pages read), by document.
|
||||
ocr_reviews: HashMap<DocId, OcrReview>,
|
||||
next_ocr_review: u64,
|
||||
/// What object_copy copied, for object_paste into any open document.
|
||||
objects_clipboard: Option<pdfcraft_engine::ObjectClipboard>,
|
||||
}
|
||||
|
||||
impl Default for Automation {
|
||||
@@ -121,6 +123,7 @@ impl Automation {
|
||||
evicted: HashSet::new(),
|
||||
ocr_reviews: HashMap::new(),
|
||||
next_ocr_review: 0,
|
||||
objects_clipboard: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,12 +204,22 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}), &["doc", "page"])),
|
||||
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
|
||||
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
|
||||
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.")
|
||||
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list (or with all: true every object on the page) by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.")
|
||||
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
|
||||
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
|
||||
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
|
||||
"all":{"type":"boolean","description":"Every object on the page, instead of objects."},
|
||||
"offset":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2},
|
||||
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","objects","offset"])),
|
||||
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page","offset"])),
|
||||
t("object_copy", "Copy or cut objects together", "Edit ▸ Copy (cut: Cut) of 1–1000 mixed references from object_list, or with all: true every object on the page, as ⌘A then ⌘C select and copy them in Edit a PDF. The copy keeps their content operators and every resource they use (fonts, images, ExtGStates, colour spaces, patterns, shadings) and is kept for object_paste into any open document, also after this one changes or closes. cut then deletes them as one undo step. Refused when the document doesn't allow copying (cut: or changes), or when text used as a clipping path is drawn before them. Returns the count copied and their text.")
|
||||
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
|
||||
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
|
||||
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
|
||||
"all":{"type":"boolean","description":"Every object on the page, instead of objects."},
|
||||
"cut":{"type":"boolean","description":"Delete them after copying (one undo step)."},
|
||||
"generation":{"type":"integer","minimum":0,"description":"Use generation returned by object_list to reject a stale selection."}}), &["doc","page"])),
|
||||
t("object_paste", "Paste copied objects", "Edit ▸ Paste of what object_copy copied, onto page of doc as page content that draws the same: where it was, or on the page it came from 18 points down and to the right, and 18 more for each further paste onto the same page; centred when that would leave it off the page. Resources join the page's under names that don't clash; into another document the fonts, images and other objects they use are copied (each once, sharing what is there; refused past 20,000 objects or 256 MB). Pasted content is untagged and in no layer. One undo step.")
|
||||
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
|
||||
t("text_word_count", "Word count", "Count Unicode words and characters on selected pages (default all), with totals and per-page counts. Table/code exclusions use PDF structure tags, not visual guesses; unclassified text is retained. Image exclusion removes Figure-tagged and invisible/OCR text. Does not run OCR. Copy permission is required. Fails rather than returning a partial total when a page cannot be read.")
|
||||
.ro()
|
||||
.cmd("file.word_count")
|
||||
|
||||
@@ -9503,6 +9503,75 @@ fn mixed_objects_move_through_tools_with_generation_and_one_undo() {
|
||||
assert!(page_text(&mut a, reopened)[0].contains("Added label"));
|
||||
}
|
||||
|
||||
/// Edit a PDF's ⌘A, ⌘C / ⌘X and ⌘V headlessly: every object of a page copied, pasted into
|
||||
/// another document and onto its own page (stepped down and right), and cut as one undo step.
|
||||
#[test]
|
||||
fn objects_copy_cut_and_paste_through_tools_within_and_across_documents() {
|
||||
let dir = workdir("object-copy-paste");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
|
||||
let other = ok(&mut a, "doc_open", json!({"path":"b.pdf"}))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Added label","rect":[20,20,130,45]}));
|
||||
assert!(matches!(a.call("object_paste", &json!({"doc":doc,"page":1})), Err(ToolError::Failed(_))), "nothing copied yet");
|
||||
let listed = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
|
||||
assert_eq!(listed["count"], 2);
|
||||
let copied = ok(&mut a, "object_copy", json!({"doc":doc,"page":1,"all":true,"generation":listed["generation"]}));
|
||||
assert_eq!(copied["copied"], 2);
|
||||
let text = copied["text"].as_str().unwrap();
|
||||
assert!(text.contains("Added label") && text.contains("Page 1"), "{text}");
|
||||
assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1})), listed, "a copy changes nothing");
|
||||
|
||||
// Into another document: the same objects, where they were, as its own page text.
|
||||
let before = ok(&mut a, "object_list", json!({"doc":other,"page":2}));
|
||||
assert_eq!(ok(&mut a, "object_paste", json!({"doc":other,"page":2}))["pasted"], 2);
|
||||
let pasted = ok(&mut a, "object_list", json!({"doc":other,"page":2}));
|
||||
assert_eq!(pasted["count"], 3);
|
||||
let rect_of =
|
||||
|list: &Value, text: &str| list["objects"].as_array().unwrap().iter().find(|o| o["text"] == text).map(|o| o["rect"].clone()).unwrap();
|
||||
assert_eq!(rect_of(&pasted, "Page 1"), rect_of(&listed, "Page 1"));
|
||||
// The added text box comes as the page's own text, drawn inside the box it had.
|
||||
let (label, box_) = (rect_of(&pasted, "Added label"), rect_of(&listed, "Added label"));
|
||||
assert!((0..2).all(|i| label[i].as_f64() >= box_[i].as_f64()) && (2..4).all(|i| label[i].as_f64() <= box_[i].as_f64()), "{label} in {box_}");
|
||||
assert!(page_text(&mut a, other)[1].contains("Added label"));
|
||||
ok(&mut a, "doc_save", json!({"doc":other,"path":"pasted.pdf"}));
|
||||
let reopened = ok(&mut a, "doc_open", json!({"path":"pasted.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert!(page_text(&mut a, reopened)[1].contains("Page 1"));
|
||||
ok(&mut a, "edit_undo", json!({"doc":other}));
|
||||
assert_eq!(ok(&mut a, "object_list", json!({"doc":other,"page":2}))["objects"], before["objects"], "one undo step");
|
||||
|
||||
// Onto its own page: 18 points down and to the right.
|
||||
ok(&mut a, "object_paste", json!({"doc":doc,"page":1}));
|
||||
let both = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
|
||||
assert_eq!(both["count"], 4);
|
||||
let original = rect_of(&listed, "Page 1");
|
||||
let moved =
|
||||
both["objects"].as_array().unwrap().iter().filter(|o| o["text"] == "Page 1").map(|o| o["rect"].clone()).find(|r| *r != original).unwrap();
|
||||
for i in 0..4 {
|
||||
assert!((moved[i].as_f64().unwrap() - original[i].as_f64().unwrap() - 18.0).abs() < 0.02, "{moved} {original}");
|
||||
}
|
||||
|
||||
// Cut: copied, then deleted as one undo step.
|
||||
let cut = ok(&mut a, "object_copy", json!({"doc":doc,"page":1,"objects":[{"kind":"text","index":1}],"cut":true}));
|
||||
assert_eq!(cut["copied"], 1);
|
||||
assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1}))["count"], 3);
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert_eq!(ok(&mut a, "object_list", json!({"doc":doc,"page":1}))["objects"], both["objects"]);
|
||||
|
||||
for args in [
|
||||
json!({"doc":doc,"page":1,"all":true,"objects":[{"kind":"text","index":1}]}),
|
||||
json!({"doc":doc,"page":1}),
|
||||
json!({"doc":doc,"page":1,"objects":[{"kind":"text","index":99}]}),
|
||||
json!({"doc":doc,"page":9,"all":true}),
|
||||
] {
|
||||
assert!(a.call("object_copy", &args).is_err(), "{args}");
|
||||
}
|
||||
assert!(
|
||||
matches!(a.call("object_copy", &json!({"doc":doc,"page":1,"all":true,"generation":listed["generation"]})), Err(ToolError::Failed(_))),
|
||||
"stale"
|
||||
);
|
||||
assert!(a.call("object_paste", &json!({"doc":doc,"page":9})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_move_rejects_invalid_nested_references_without_any_change() {
|
||||
let dir = workdir("object-move-refusals");
|
||||
|
||||
@@ -1884,6 +1884,24 @@ fn crop_image_impl(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The `Do` of each image `indexes` (from [`page_images`]) on `page`, as an index among the
|
||||
/// page's joined operators: what Copy takes of them.
|
||||
pub(crate) fn image_ops(doc: &Document, page: usize, indexes: &[usize]) -> Result<Vec<usize>, EditError> {
|
||||
if indexes.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let images = page_images(doc, page)?;
|
||||
indexes
|
||||
.iter()
|
||||
.map(|i| {
|
||||
images
|
||||
.get(*i)
|
||||
.map(|image| image.op)
|
||||
.ok_or_else(|| EditError::Invalid("a selected image no longer exists; select the objects again".into()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Plan all transforms before changing a stream; indexes refer to the original inventory.
|
||||
/// Splice only the chosen Do operators and preserve all other bytes and stream dictionaries.
|
||||
pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize], offset: [f64; 2]) -> Result<(), EditError> {
|
||||
|
||||
@@ -46,7 +46,9 @@ pub use background::page_background;
|
||||
mod rules;
|
||||
pub use rules::page_rules;
|
||||
pub mod objects;
|
||||
pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects};
|
||||
pub use objects::{
|
||||
CopiedObjects, EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, copy_objects, editable_objects, move_objects, paste_objects,
|
||||
};
|
||||
|
||||
mod flatten;
|
||||
pub use flatten::{flatten, flatten_fill_sign};
|
||||
|
||||
@@ -9,6 +9,9 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
|
||||
use crate::text::ContentStream;
|
||||
use crate::{Content, EditError};
|
||||
|
||||
mod clip;
|
||||
pub use clip::{CopiedObjects, copy_objects, paste_objects};
|
||||
|
||||
pub const MAX_MOVE_OBJECTS: usize = 1_000;
|
||||
const MAX_PAGE_OBJECTS: usize = 10_000;
|
||||
const MAX_COORDINATE: f64 = 1e9;
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Edit a PDF ▸ Copy, Cut and Paste of objects selected together (#5989): paragraphs, images,
|
||||
//! Form artwork and added content, pasted as page content that draws the same.
|
||||
//!
|
||||
//! A copy is a content stream of its own that draws the objects and nothing else, in the page's
|
||||
//! user space, with the resources it names. It replays every state operator of the page up to
|
||||
//! the last object (transformations, clips, colours, ExtGStates, text state), so each object is
|
||||
//! drawn in the state it had, and leaves out what paints anything else: other text, images,
|
||||
//! shadings and paths (a path that clips is kept, without its paint). Each copied text-showing
|
||||
//! operator is placed by its own text matrix, which is where its glyphs start, so the text
|
||||
//! positioning between operators is left out. Marked content is left out too: a paste is
|
||||
//! untagged and in no layer.
|
||||
//!
|
||||
//! A paste draws that stream on top of the page through Ungroup's merge ([`ungroup_ops`]): the
|
||||
//! copy is read as Form artwork whose resources join the page's under names that don't clash,
|
||||
//! moved by the offset, with the patterns it paints moved along. Its resources must already be
|
||||
//! the target document's: the engine copies them over first when the copy came from another
|
||||
//! document.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, serialize_ops};
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
|
||||
|
||||
use super::{EditableObject, MAX_COORDINATE, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, checked_streams, editable_objects, valid_rect};
|
||||
use crate::EditError;
|
||||
use crate::ungroup::{CATEGORIES, DEFAULT_SPACES, MAX_FORM_BYTES, Ungrouped, page_blend_space, resource_operand, ungroup_ops};
|
||||
|
||||
/// How deep the copied graphics state may nest (`q`), as the page's own may.
|
||||
const MAX_NESTING: usize = 1024;
|
||||
|
||||
/// Objects copied from one page: what [`paste_objects`] draws again.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CopiedObjects {
|
||||
/// A content stream that draws the objects alone, in the source page's user space: its `q`
|
||||
/// and `Q` balance and its text objects close.
|
||||
pub content: Vec<u8>,
|
||||
/// The resources `content` names, as the source page has them: references are into the
|
||||
/// document the copy came from.
|
||||
pub resources: Dict,
|
||||
/// The union of the objects' boxes (user space of the source page).
|
||||
pub rect: [f64; 4],
|
||||
/// The objects as [`editable_objects`] listed them, in inventory order.
|
||||
pub objects: Vec<EditableObject>,
|
||||
}
|
||||
|
||||
impl CopiedObjects {
|
||||
/// The text of the copied paragraphs and text boxes, one per line: what the system clipboard
|
||||
/// gets for other applications.
|
||||
pub fn text(&self) -> String {
|
||||
self.objects.iter().filter_map(|o| o.text.as_deref()).filter(|t| !t.is_empty()).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy `targets` (references from one [`editable_objects`] inventory of `page`). The document
|
||||
/// is not changed.
|
||||
pub fn copy_objects(doc: &Document, page: usize, targets: &[ObjectTarget]) -> Result<CopiedObjects, EditError> {
|
||||
if targets.is_empty() || targets.len() > MAX_MOVE_OBJECTS {
|
||||
return Err(EditError::Invalid("select between 1 and 1000 objects to copy".into()));
|
||||
}
|
||||
let mut inventory: BTreeMap<_, _> = editable_objects(doc, page)?.into_iter().map(|o| (o.target, o)).collect();
|
||||
let mut objects = Vec::with_capacity(targets.len());
|
||||
for target in targets.iter().collect::<BTreeSet<_>>() {
|
||||
objects
|
||||
.push(inventory.remove(target).ok_or_else(|| EditError::Invalid("a selected object no longer exists; select the objects again".into()))?);
|
||||
}
|
||||
if objects.len() != targets.len() {
|
||||
return Err(EditError::Invalid("an object was selected more than once".into()));
|
||||
}
|
||||
let indexes = |kind| objects.iter().filter(|o| o.target.kind == kind).map(|o| o.target.index).collect::<Vec<_>>();
|
||||
let streams = checked_streams(doc, page)?;
|
||||
let pieces = crate::text::joined(&streams);
|
||||
let ops = pieces.parse().ops;
|
||||
let mut keep = crate::text::paragraph_ops(doc, page, &indexes(ObjectKind::Text), false)?;
|
||||
keep.extend(crate::images::image_ops(doc, page, &indexes(ObjectKind::Image))?);
|
||||
// Added content owns its stream: everything that stream paints is the item.
|
||||
let added: Vec<_> = crate::list_added(doc).into_iter().filter(|a| a.page == page).collect();
|
||||
let owned = indexes(ObjectKind::Added)
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
added.get(i).map(|a| a.obj).ok_or_else(|| EditError::Invalid("a selected added item no longer exists; select the objects again".into()))
|
||||
})
|
||||
.collect::<Result<BTreeSet<ObjRef>, _>>()?;
|
||||
if !owned.is_empty() {
|
||||
for (i, op) in ops.iter().enumerate() {
|
||||
let stream = streams.get(pieces.pieces_of(op).0).and_then(|s| s.entry.as_ref());
|
||||
if paints(op) && stream.is_some_and(|r| owned.contains(&r)) {
|
||||
keep.insert(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?;
|
||||
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let shows = crate::text::show_starts(doc, &ops, &res)?;
|
||||
let drawn = replay(&ops, &keep, &shows)?;
|
||||
let rect = objects.iter().map(|o| o.rect).reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])]).unwrap_or_default();
|
||||
Ok(CopiedObjects { resources: used_resources(doc, &res, &drawn), content: serialize_ops(&drawn), rect, objects })
|
||||
}
|
||||
|
||||
/// Whether `op` paints (rather than sets state or builds a path).
|
||||
fn paints(op: &Op) -> bool {
|
||||
matches!(op.op.as_slice(), b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" | b"Do" | b"sh" | b"BI")
|
||||
}
|
||||
|
||||
/// The page's operators up to the last of `keep`, painting only `keep` (see the module docs).
|
||||
/// `shows`: where each text-showing operator starts ([`crate::text::show_starts`]).
|
||||
fn replay(ops: &[Op], keep: &BTreeSet<usize>, shows: &HashMap<usize, (Matrix, i64)>) -> Result<Vec<Op>, EditError> {
|
||||
let Some(&last) = keep.last() else { return Err(EditError::Invalid("the selected objects draw nothing to copy".into())) };
|
||||
let mut out = Vec::new();
|
||||
let (mut depth, mut in_text) = (0usize, false);
|
||||
let (mut path, mut clips) = (Vec::new(), false);
|
||||
for (i, op) in ops.iter().enumerate().take(last.saturating_add(1)) {
|
||||
let kept = keep.contains(&i);
|
||||
match op.op.as_slice() {
|
||||
b"q" => {
|
||||
if depth >= MAX_NESTING {
|
||||
return Err(EditError::Invalid("the page graphics state is nested too deeply".into()));
|
||||
}
|
||||
depth += 1;
|
||||
out.push(op.clone());
|
||||
}
|
||||
// An unmatched `Q` restores nothing; the renderer ignores it too.
|
||||
b"Q" if depth > 0 => {
|
||||
depth -= 1;
|
||||
out.push(op.clone());
|
||||
}
|
||||
b"BT" if !in_text => {
|
||||
in_text = true;
|
||||
out.push(op.clone());
|
||||
}
|
||||
b"ET" if in_text => {
|
||||
in_text = false;
|
||||
out.push(op.clone());
|
||||
}
|
||||
b"Q" | b"BT" | b"ET" => {}
|
||||
b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" => path.push(op.clone()),
|
||||
b"W" | b"W*" => {
|
||||
clips = true;
|
||||
path.push(op.clone());
|
||||
}
|
||||
b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => {
|
||||
if kept || clips {
|
||||
out.append(&mut path);
|
||||
out.push(if kept { op.clone() } else { Op::new("n", Vec::new()) });
|
||||
}
|
||||
path.clear();
|
||||
clips = false;
|
||||
}
|
||||
b"Tj" | b"TJ" | b"'" | b"\"" => {
|
||||
// `"` sets the spacing for what follows, shown or not.
|
||||
if op.is("\"") {
|
||||
for (name, value) in ["Tw", "Tc"].into_iter().zip(op.operands.iter().take(2)) {
|
||||
if value.as_f64().is_some() {
|
||||
out.push(Op::new(name, vec![value.clone()]));
|
||||
}
|
||||
}
|
||||
}
|
||||
match shows.get(&i) {
|
||||
Some((tm, _)) if kept => {
|
||||
out.push(Op::new("Tm", tm.0.map(Object::Real).to_vec()));
|
||||
out.push(if op.is("TJ") { op.clone() } else { Op::new("Tj", op.operands.last().cloned().into_iter().collect()) });
|
||||
}
|
||||
// Its glyphs clip what follows, which leaving them out would not.
|
||||
Some((_, mode)) if *mode >= 4 => {
|
||||
return Err(EditError::Invalid(
|
||||
"text used as a clipping path is drawn before the selected objects, so they can't be copied".into(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Each copied show sets its own text matrix; `TD` also sets the leading.
|
||||
b"TD" => {
|
||||
if let Some([_, y]) = op.nums::<2>() {
|
||||
out.push(Op::new("TL", vec![Object::Real(-y)]));
|
||||
}
|
||||
}
|
||||
b"Td" | b"Tm" | b"T*" | b"BDC" | b"BMC" | b"EMC" | b"MP" | b"DP" => {}
|
||||
b"Do" | b"sh" | b"BI" => {
|
||||
if kept {
|
||||
out.push(op.clone());
|
||||
}
|
||||
}
|
||||
_ => out.push(op.clone()),
|
||||
}
|
||||
}
|
||||
if in_text {
|
||||
out.push(Op::new("ET", Vec::new()));
|
||||
}
|
||||
out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The entries of the page's resources `res` that `ops` name, and the default colour spaces,
|
||||
/// which remap the device colours they paint (ISO 32000-2 §8.6.5.6).
|
||||
fn used_resources(doc: &Document, res: &Dict, ops: &[Op]) -> Dict {
|
||||
let mut used: BTreeMap<&[u8], BTreeSet<&[u8]>> = BTreeMap::new();
|
||||
for op in ops {
|
||||
if let Ok(Some((cat, i))) = resource_operand(op)
|
||||
&& let Some(Object::Name(name)) = op.operands.get(i)
|
||||
{
|
||||
used.entry(cat).or_default().insert(name.as_slice());
|
||||
}
|
||||
}
|
||||
let mut out = Dict::new();
|
||||
for cat in CATEGORIES {
|
||||
let Some(all) = res.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()) else { continue };
|
||||
let mut mine = Dict::new();
|
||||
for (key, value) in all.iter() {
|
||||
if used.get(cat).is_some_and(|u| u.contains(key.as_slice())) || (cat == b"ColorSpace" && DEFAULT_SPACES.contains(&key.as_slice())) {
|
||||
mine.set(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
if !mine.is_empty() {
|
||||
out.set(cat.to_vec(), Object::Dict(mine));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Paste `copied` on top of `page`, moved by `offset` (user space), as one change: its content
|
||||
/// and the page's resources merged with its own. Its resources must be this document's.
|
||||
pub fn paste_objects(doc: &mut Document, page: usize, copied: &CopiedObjects, offset: [f64; 2]) -> Result<(), EditError> {
|
||||
let r = copied.rect;
|
||||
if !offset.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORDINATE)
|
||||
|| !valid_rect([r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]])
|
||||
{
|
||||
return Err(EditError::Invalid("the paste position is out of range".into()));
|
||||
}
|
||||
let p = pdfcraft_model::pages(doc).into_iter().nth(page).ok_or(EditError::NoSuchPage(page))?;
|
||||
crate::check_on_top(doc, &p)?;
|
||||
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let name = (1..=xobjects.len().saturating_add(1))
|
||||
.map(|n| format!("PCPaste{n}").into_bytes())
|
||||
.find(|n| xobjects.get(n).is_none())
|
||||
.ok_or_else(|| EditError::Invalid("the page has too many resources".into()))?;
|
||||
// The copy as Form artwork, to draw inline as Ungroup does: its resources merge into the
|
||||
// page's, and patterns move with it. The form itself is never added to the document.
|
||||
let number = |v: f64| Object::Real(v);
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
// Nothing of the copy is clipped: its glyphs and strokes may reach past its objects' boxes.
|
||||
form.set(b"BBox".to_vec(), Object::Array([-MAX_COORDINATE, -MAX_COORDINATE, MAX_COORDINATE, MAX_COORDINATE].map(number).to_vec()));
|
||||
form.set(b"Matrix".to_vec(), Object::Array(Matrix::translate(offset[0], offset[1]).0.map(number).to_vec()));
|
||||
form.set(b"Resources".to_vec(), Object::Dict(copied.resources.clone()));
|
||||
xobjects.set(name.clone(), Object::Stream(Stream::from_raw(form, copied.content.clone())));
|
||||
let mut with_copy = res;
|
||||
with_copy.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
let space = page_blend_space(doc, &p.dict);
|
||||
// Drawn by a `Do` of its own: not translucent, and in no path or text object.
|
||||
let draw = [Op::new("Do", vec![Object::Name(name.clone())])];
|
||||
let mut budget = pdfcraft_cos::DecodeBudget::new(MAX_FORM_BYTES, 0);
|
||||
let Ungrouped { ops, resources: mut merged, patterns, .. } =
|
||||
ungroup_ops(doc, &with_copy, &draw, 0, space.as_ref(), Matrix::translate(offset[0], offset[1]), &mut budget).map_err(|r| match r.error {
|
||||
EditError::Invalid(why) => {
|
||||
EditError::Invalid(why.replacen("this artwork can't be ungrouped", "the copied objects can't be pasted here", 1))
|
||||
}
|
||||
other => other,
|
||||
})?;
|
||||
let mut work = doc.clone();
|
||||
if let Some(mut x) = merged.get(b"XObject").and_then(Object::as_dict).cloned() {
|
||||
x.remove(&name);
|
||||
merged.set(b"XObject".to_vec(), Object::Dict(x));
|
||||
}
|
||||
if !patterns.is_empty() {
|
||||
let mut table = merged.get(b"Pattern").map(|x| work.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
for (key, pattern) in patterns {
|
||||
table.set(key, Object::Ref(work.add(pattern)));
|
||||
}
|
||||
merged.set(b"Pattern".to_vec(), Object::Dict(table));
|
||||
}
|
||||
work.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(merged)))?;
|
||||
crate::place_tagged(&mut work, &p, "Pasted", serialize_ops(&ops), false)?;
|
||||
// Keep the page's objects selectable together, as a move does.
|
||||
editable_objects(&work, page)?;
|
||||
*doc = work;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "clip_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,228 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{Document, SaveOptions, write_incremental};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A document of `objs` (object 1 the catalog), numbered in order.
|
||||
fn pdf(objs: &[String]) -> Document {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
fn stream(content: &str) -> String {
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())
|
||||
}
|
||||
|
||||
/// Two 300 × 300 pages. The first draws `source` with Helvetica as /F1, a 4 × 2 RGB image /Im0,
|
||||
/// a half-opaque ExtGState /GS0 and Courier as /F2; the second draws `target` with Courier
|
||||
/// under the same name /F1, so a paste from the first must rename the font it brings.
|
||||
fn two_pages(source: &str, target: &str) -> Document {
|
||||
pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R /F2 8 0 R >> /XObject << /Im0 9 0 R >> /ExtGState << /GS0 << /ca 0.5 /CA 0.5 >> >> >> >>"
|
||||
.into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 8 0 R >> >> >>".into(),
|
||||
stream(source),
|
||||
stream(target),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 24 >>\nstream\n\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}\u{7f}@@@\u{0}\u{0}\u{0}\nendstream".into(),
|
||||
])
|
||||
}
|
||||
|
||||
/// What the page state, the copied paragraph and picture and the objects left out are drawn with.
|
||||
const SOURCE: &str = "q 1 0 0 1 10 10 cm 0 0 270 270 re W n 0 0 1 rg /GS0 gs \
|
||||
BT /F2 10 Tf 200 20 Td (Skip) Tj ET \
|
||||
BT /F1 24 Tf 1.5 Tc 20 170 Td (Copied) Tj 0 -30 Td (Too) Tj ET \
|
||||
q 1 0 0 rg 150 230 100 40 re f Q \
|
||||
q 80 0 0 40 20 60 cm /Im0 Do Q Q \
|
||||
BT /F1 12 Tf 200 250 Td (After) Tj ET";
|
||||
|
||||
fn target(doc: &Document, page: usize, want: impl Fn(&EditableObject) -> bool) -> ObjectTarget {
|
||||
editable_objects(doc, page).unwrap().into_iter().find(|o| want(o)).unwrap().target
|
||||
}
|
||||
|
||||
fn render(doc: &Document, page: usize) -> pdfcraft_render::RenderedPage {
|
||||
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default());
|
||||
renderer.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() })
|
||||
}
|
||||
|
||||
/// The pixels of `page` in user-space box `r` (a page 300 high at scale 1), shifted by `by`.
|
||||
fn pixels(page: &pdfcraft_render::RenderedPage, r: [f64; 4], by: [f64; 2]) -> Vec<u8> {
|
||||
let (x0, x1) = ((r[0] + by[0]) as u32, (r[2] + by[0]) as u32);
|
||||
let (y0, y1) = ((300.0 - r[3] - by[1]) as u32, (300.0 - r[1] - by[1]) as u32);
|
||||
let mut out = Vec::new();
|
||||
for y in y0..y1 {
|
||||
for x in x0..x1 {
|
||||
let i = ((y * page.width + x) * 4) as usize;
|
||||
out.extend_from_slice(&page.rgba[i..i + 4]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The paragraph "Copied" / "Too" (one or two of them, as the lines group) and the picture.
|
||||
fn copied_paragraph_and_picture(doc: &Document) -> CopiedObjects {
|
||||
let targets: Vec<_> = editable_objects(doc, 0)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.filter(|o| o.target.kind == ObjectKind::Image || o.text.as_deref().is_some_and(|t| t.contains("Copied") || t.contains("Too")))
|
||||
.map(|o| o.target)
|
||||
.collect();
|
||||
copy_objects(doc, 0, &targets).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_takes_the_selected_objects_their_state_and_only_the_resources_they_use() {
|
||||
let doc = two_pages(SOURCE, "");
|
||||
let copied = copied_paragraph_and_picture(&doc);
|
||||
let content = String::from_utf8_lossy(&copied.content).into_owned();
|
||||
assert!(content.contains("(Copied)") && content.contains("(Too)") && content.contains("/Im0 Do"), "{content}");
|
||||
// The page's clip and its colour and transparency still apply; nothing else is painted.
|
||||
assert!(content.contains("W\nn") && content.contains("/GS0 gs") && content.contains("rg"), "{content}");
|
||||
assert!(!content.contains("Skip") && !content.contains("After") && !content.contains("\nf\n"), "{content}");
|
||||
let names = |cat: &[u8]| copied.resources.get(cat).and_then(Object::as_dict).map(|d| d.iter().map(|(k, _)| k.clone()).collect::<Vec<_>>());
|
||||
assert_eq!(names(b"Font"), Some(vec![b"F1".to_vec(), b"F2".to_vec()]), "the font of the skipped text stays selected for what follows");
|
||||
assert_eq!(names(b"XObject"), Some(vec![b"Im0".to_vec()]));
|
||||
assert_eq!(names(b"ExtGState"), Some(vec![b"GS0".to_vec()]));
|
||||
assert!(copied.text().contains("Copied") && copied.text().contains("Too") && !copied.text().contains("Skip"), "{}", copied.text());
|
||||
assert_eq!(pdfcraft_content::parse(&copied.content).ops.iter().filter(|o| o.is("q")).count(), 3, "balanced: {content}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_objects_draw_the_same_on_another_page_under_renamed_resources() {
|
||||
let mut doc = two_pages(SOURCE, "BT /F1 10 Tf 5 5 Td (x) Tj ET");
|
||||
let copied = copied_paragraph_and_picture(&doc);
|
||||
paste_objects(&mut doc, 1, &copied, [0.0, 0.0]).unwrap();
|
||||
let (source, pasted) = (render(&doc, 0), render(&doc, 1));
|
||||
let area = [copied.rect[0] - 4.0, copied.rect[1] - 4.0, copied.rect[2] + 4.0, copied.rect[3] + 4.0];
|
||||
let (before, after) = (pixels(&source, area, [0.0, 0.0]), pixels(&pasted, area, [0.0, 0.0]));
|
||||
assert!(before.chunks(4).any(|p| p != [255, 255, 255, 255]), "the copied objects are drawn");
|
||||
assert_eq!(before, after, "the paste draws the same pixels");
|
||||
let red = pixels(&pasted, [160.0, 240.0, 260.0, 280.0], [0.0, 0.0]);
|
||||
assert!(red.chunks(4).all(|p| p == [255, 255, 255, 255]), "what wasn't selected isn't pasted");
|
||||
// Its own /F1 (Courier) is left alone; the copy's Helvetica comes under another name.
|
||||
let fonts =
|
||||
pdfcraft_model::pages(&doc)[1].dict.get(b"Resources").map(|r| doc.resolve(r)).unwrap().as_dict().unwrap().get(b"Font").cloned().unwrap();
|
||||
let fonts = doc.resolve(&fonts).as_dict().cloned().unwrap();
|
||||
assert_eq!(fonts.get(b"F1"), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(8, 0))));
|
||||
assert!(fonts.iter().any(|(k, v)| k != b"F1" && *v == Object::Ref(pdfcraft_cos::ObjRef::new(7, 0))), "{fonts:?}");
|
||||
let texts: Vec<_> = editable_objects(&doc, 1).unwrap().into_iter().filter_map(|o| o.text).collect();
|
||||
assert!(texts.iter().any(|t| t.contains("Copied")) && !texts.iter().any(|t| t.contains("Skip")), "{texts:?}");
|
||||
assert_eq!(crate::images::page_images(&doc, 1).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paste_onto_its_own_page_moves_by_the_offset_and_draws_the_same() {
|
||||
let mut doc = two_pages(SOURCE, "");
|
||||
let copied = copied_paragraph_and_picture(&doc);
|
||||
let before = render(&doc, 0);
|
||||
paste_objects(&mut doc, 0, &copied, [150.0, 0.0]).unwrap();
|
||||
let after = render(&doc, 0);
|
||||
let area = [copied.rect[0] - 2.0, copied.rect[1] - 2.0, copied.rect[2] + 2.0, copied.rect[3] + 2.0];
|
||||
assert!(area[2] + 150.0 < 300.0, "{area:?}");
|
||||
// The copy (and the clip it is drawn in) lands 150 pt to the right, the original stays.
|
||||
// Antialiasing elsewhere on the page may round an edge pixel one step differently.
|
||||
let (moved, original) = (pixels(&after, area, [150.0, 0.0]), pixels(&before, area, [0.0, 0.0]));
|
||||
assert_eq!(moved.len(), original.len());
|
||||
assert!(original.iter().any(|v| *v != 255), "the copied objects are drawn");
|
||||
assert!(moved.iter().zip(&original).all(|(a, b)| a.abs_diff(*b) <= 2), "the paste draws the same pixels, moved");
|
||||
assert_eq!(pixels(&after, area, [0.0, 0.0]), original);
|
||||
// Within the document and the page, the resources are the page's own, under their names.
|
||||
let res = pdfcraft_model::pages(&doc)[0].dict.get(b"Resources").map(|r| doc.resolve(r)).unwrap();
|
||||
let fonts = res.as_dict().unwrap().get(b"Font").and_then(Object::as_dict).cloned().unwrap();
|
||||
assert_eq!(fonts.len(), 2, "{fonts:?}");
|
||||
assert_eq!(editable_objects(&doc, 0).unwrap().iter().filter(|o| o.target.kind == ObjectKind::Image).count(), 2);
|
||||
}
|
||||
|
||||
/// Artwork without resources of its own takes the names of the page drawing it. Pasted onto a
|
||||
/// page whose names differ, artwork that names nothing still draws the same; artwork that names
|
||||
/// a font would find another one, so the paste is refused.
|
||||
#[test]
|
||||
fn artwork_inheriting_resources_pastes_only_when_it_names_none() {
|
||||
for (art, pastes) in [("0 0 1 rg 0 0 60 30 re f", true), ("BT /F1 9 Tf (n) Tj ET", false)] {
|
||||
let mut doc = pdf(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 300] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> /XObject << /Art 8 0 R >> >> >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 9 0 R >> >> >>".into(),
|
||||
stream("BT /F1 12 Tf 20 250 Td (Title) Tj ET q 1 0 0 1 20 100 cm /Art Do Q"),
|
||||
stream(""),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 60 30] /Length {} >>\nstream\n{art}\nendstream", art.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".into(),
|
||||
]);
|
||||
let all: Vec<_> = editable_objects(&doc, 0).unwrap().into_iter().map(|o| o.target).collect();
|
||||
let copied = copy_objects(&doc, 0, &all).unwrap();
|
||||
let pasted = paste_objects(&mut doc, 1, &copied, [0.0, 0.0]);
|
||||
assert_eq!(pasted.is_ok(), pastes, "{art}: {pasted:?}");
|
||||
if let Err(EditError::Invalid(why)) = pasted {
|
||||
assert!(why.starts_with("the copied objects can't be pasted here") && why.contains("uses its resources"), "{why}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_content_is_copied_with_everything_its_stream_paints() {
|
||||
let mut doc = two_pages(SOURCE, "");
|
||||
let item = crate::Content::Text(crate::AddedText {
|
||||
rect: [20.0, 20.0, 200.0, 60.0],
|
||||
text: "Added box".into(),
|
||||
size: 12.0,
|
||||
..crate::AddedText::default()
|
||||
});
|
||||
crate::add_content(&mut doc, 0, &item).unwrap();
|
||||
let added = target(&doc, 0, |o| o.target.kind == ObjectKind::Added);
|
||||
let copied = copy_objects(&doc, 0, &[added]).unwrap();
|
||||
assert_eq!(copied.text(), "Added box");
|
||||
paste_objects(&mut doc, 1, &copied, [0.0, 0.0]).unwrap();
|
||||
let texts: Vec<_> = editable_objects(&doc, 1).unwrap().into_iter().filter_map(|o| o.text).collect();
|
||||
assert_eq!(texts, ["Added box"], "pasted as the page's own text");
|
||||
assert!(crate::list_added(&doc).iter().all(|a| a.page == 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_and_pasting_refuse_what_they_cannot_do_and_change_nothing() {
|
||||
let doc = two_pages(SOURCE, "");
|
||||
let text = target(&doc, 0, |o| o.text.as_deref().is_some_and(|t| t.contains("Copied")));
|
||||
assert!(copy_objects(&doc, 0, &[]).is_err());
|
||||
assert!(copy_objects(&doc, 0, &[text, text]).is_err(), "selected twice");
|
||||
assert!(copy_objects(&doc, 0, &[ObjectTarget { kind: ObjectKind::Image, index: 9 }]).is_err(), "stale");
|
||||
assert!(copy_objects(&doc, 5, &[text]).is_err(), "no such page");
|
||||
let copied = copy_objects(&doc, 0, &[text]).unwrap();
|
||||
for offset in [[f64::NAN, 0.0], [f64::INFINITY, 0.0], [2e9, 0.0]] {
|
||||
let mut work = doc.clone();
|
||||
assert!(paste_objects(&mut work, 1, &copied, offset).is_err(), "{offset:?}");
|
||||
assert!(!work.is_modified(), "a refused paste changes nothing");
|
||||
}
|
||||
assert!(paste_objects(&mut doc.clone(), 7, &copied, [0.0, 0.0]).is_err());
|
||||
|
||||
// Text drawn as a clipping path before the selection would clip it: refused, not dropped.
|
||||
let clipped = two_pages("BT /F1 20 Tf 7 Tr 20 20 Td (Mask) Tj ET 0 Tr BT /F1 20 Tf 20 200 Td (Shown) Tj ET", "");
|
||||
let shown = target(&clipped, 0, |o| o.text.as_deref() == Some("Shown"));
|
||||
assert!(copy_objects(&clipped, 0, &[shown]).is_err());
|
||||
|
||||
// Stray `Q`, `ET` and an unfinished path or text object: the copy is still balanced.
|
||||
let stray = two_pages("Q Q ET 10 10 m 20 20 l BT /F1 20 Tf 20 200 Td (Stray) Tj", "");
|
||||
let shown = target(&stray, 0, |o| o.text.as_deref() == Some("Stray"));
|
||||
let copied = copy_objects(&stray, 0, &[shown]).unwrap();
|
||||
let ops = pdfcraft_content::parse(&copied.content).ops;
|
||||
assert_eq!(ops.iter().filter(|o| o.is("q")).count(), ops.iter().filter(|o| o.is("Q")).count());
|
||||
assert_eq!(ops.iter().filter(|o| o.is("BT")).count(), ops.iter().filter(|o| o.is("ET")).count());
|
||||
let mut work = stray.clone();
|
||||
paste_objects(&mut work, 1, &copied, [0.0, 0.0]).unwrap();
|
||||
}
|
||||
@@ -63,9 +63,9 @@ pub(crate) use lines::{
|
||||
pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines};
|
||||
pub use rewrite::{LineEdit, replace_line, rewrite_block};
|
||||
use rewrite::{coincident_ops, rewrite_block_from};
|
||||
pub(crate) use rewrite::{paragraph_ops, reveal_ops, selectable_blocks, show_starts, translate_blocks};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rewrite::{replace_line_with_limit, rewrite_block_with_limit};
|
||||
pub(crate) use rewrite::{reveal_ops, selectable_blocks, translate_blocks};
|
||||
|
||||
/// One line of existing text.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -1117,6 +1117,36 @@ fn vertical_encoding(doc: &Document, font: &Dict) -> Result<bool, EditError> {
|
||||
Err(EditError::Invalid("the text encoding is nested too deeply".into()))
|
||||
}
|
||||
|
||||
/// The text-showing operators of paragraphs `indexes` (from [`group_blocks`] of the page's
|
||||
/// lines), as indexes among the page's joined operators. `editable`: refuse a line that can't be
|
||||
/// edited (moving rewrites its operators; copying takes them as they are).
|
||||
pub(crate) fn paragraph_ops(doc: &Document, page: usize, indexes: &[usize], editable: bool) -> Result<std::collections::BTreeSet<usize>, EditError> {
|
||||
let mut selected = std::collections::BTreeSet::new();
|
||||
if indexes.is_empty() {
|
||||
return Ok(selected);
|
||||
}
|
||||
let lines = text_lines(doc, page)?;
|
||||
let blocks = group_blocks(&lines);
|
||||
for index in indexes {
|
||||
let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?;
|
||||
for line in &b.lines {
|
||||
let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?;
|
||||
if editable {
|
||||
l.ensure_editable()?;
|
||||
}
|
||||
selected.extend(l.ops.iter().copied());
|
||||
}
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
/// Where each text-showing operator of `ops` (a page's joined operators, drawn with `resources`)
|
||||
/// starts its glyphs: the text matrix there, and its rendering mode (`Tr`).
|
||||
pub(crate) fn show_starts(doc: &Document, ops: &[Op], resources: &Dict) -> Result<HashMap<usize, (Matrix, i64)>, EditError> {
|
||||
let shown = interpret(doc, ops, resources, &mut HashMap::new(), &mut Carry::new(), None, None)?;
|
||||
Ok(shown.into_iter().map(|s| (s.op, (s.tm, s.state.render_mode))).collect())
|
||||
}
|
||||
|
||||
/// Translate paragraphs together without re-encoding glyphs, changing fonts or reflowing.
|
||||
/// Numeric TJ offsets and text rise temporarily move each selected show operation, then
|
||||
/// restore both advances and rise. The PDF reader advances the original glyph bytes itself:
|
||||
@@ -1127,17 +1157,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
|
||||
}
|
||||
let p = page_dict(doc, page)?;
|
||||
let streams = content_streams(doc, &p.dict)?;
|
||||
let lines = text_lines(doc, page)?;
|
||||
let blocks = group_blocks(&lines);
|
||||
let mut selected = std::collections::BTreeSet::new();
|
||||
for index in indexes {
|
||||
let b = blocks.get(*index).ok_or_else(|| EditError::Invalid("a selected paragraph no longer exists".into()))?;
|
||||
for line in &b.lines {
|
||||
let l = lines.get(*line).ok_or_else(|| EditError::Invalid("a selected text line no longer exists".into()))?;
|
||||
l.ensure_editable()?;
|
||||
selected.extend(l.ops.iter().copied());
|
||||
}
|
||||
}
|
||||
let selected = paragraph_ops(doc, page, indexes, true)?;
|
||||
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let fonts = res.get(b"Font").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let pieces = joined(&streams);
|
||||
|
||||
+33
-14
@@ -41,11 +41,11 @@ use crate::EditError;
|
||||
pub(crate) const MAX_FORM_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34).
|
||||
const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
|
||||
pub(crate) const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
|
||||
|
||||
/// The `/ColorSpace` entries that remap device colours painted with the resources they are in
|
||||
/// (ISO 32000-2 §8.6.5.6).
|
||||
const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK"];
|
||||
pub(crate) const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK"];
|
||||
|
||||
/// The most distinct patterns one ungrouped form may select; each becomes a new object.
|
||||
const MAX_MOVED_PATTERNS: usize = 256;
|
||||
@@ -258,7 +258,7 @@ pub(crate) fn ungroup_ops(
|
||||
// up in the form's (the renderer chains them to whatever draws it). Drawn from the page, it
|
||||
// would find the page's, which differ wherever a name was renamed (every form resource that
|
||||
// isn't already the page's, the nested item's own entry included).
|
||||
if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) {
|
||||
if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op, budget)) {
|
||||
return Err(Refusal { renaming: true, ..refuse("it draws a nested form, Type 3 font or pattern that uses its resources") });
|
||||
}
|
||||
let mut depth = 0usize;
|
||||
@@ -505,7 +505,7 @@ fn shows_text(op: &Op) -> bool {
|
||||
}
|
||||
|
||||
/// An operator whose operands the renderer can't read as it expects, so it skips the operator.
|
||||
struct Unreadable;
|
||||
pub(crate) struct Unreadable;
|
||||
|
||||
/// Which operand of `op` names a resource, and from which category, read as the renderer reads
|
||||
/// it: operands are taken from the end, so a surplus operand before them is ignored. `Do`, `gs`,
|
||||
@@ -513,7 +513,7 @@ struct Unreadable;
|
||||
/// a name or an inline dictionary; `scn` and `SCN` numbers and the last name among them.
|
||||
/// `Ok(None)` when `op` names no resource; [`Unreadable`] when the renderer would skip it, which
|
||||
/// [`ungroup_ops`] refuses rather than guess at.
|
||||
fn resource_operand(op: &Op) -> Result<Option<(&'static [u8], usize)>, Unreadable> {
|
||||
pub(crate) fn resource_operand(op: &Op) -> Result<Option<(&'static [u8], usize)>, Unreadable> {
|
||||
let operands = &op.operands;
|
||||
let name_at = |i: Option<usize>| i.is_some_and(|i| matches!(operands.get(i), Some(Object::Name(_))));
|
||||
let last = operands.len().checked_sub(1);
|
||||
@@ -553,25 +553,44 @@ fn resource_operand(op: &Op) -> Result<Option<(&'static [u8], usize)>, Unreadabl
|
||||
}
|
||||
|
||||
/// Whether `op` draws a Form XObject (`Do`), Type 3 font (`Tf`) or tiling pattern (`scn`/`SCN`) from
|
||||
/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with.
|
||||
fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool {
|
||||
/// `resources` that has no `/Resources` of its own, and so inherits the ones it is drawn with. A
|
||||
/// form or pattern whose content names no resource inherits nothing (a filled shape, say), so it
|
||||
/// needs no renaming; its content is read within `budget`. A Type 3 font's glyphs are not read,
|
||||
/// and always count.
|
||||
fn inherits_resources(doc: &Document, resources: &Dict, op: &Op, budget: &mut DecodeBudget) -> bool {
|
||||
let Ok(Some((cat @ (b"XObject" | b"Font" | b"Pattern"), i))) = resource_operand(op) else { return false };
|
||||
let Some(Object::Name(name)) = op.operands.get(i) else { return false };
|
||||
let Some(entry) = resources.get(cat).map(|d| doc.resolve(d)).and_then(|d| d.as_dict().and_then(|d| d.get(name).cloned())) else {
|
||||
return false;
|
||||
};
|
||||
let dict = match &*doc.resolve(&entry) {
|
||||
Object::Stream(s) => s.dict.clone(),
|
||||
Object::Dict(d) => d.clone(),
|
||||
let entry = doc.resolve(&entry);
|
||||
let (dict, stream) = match &*entry {
|
||||
Object::Stream(s) => (&s.dict, Some(s)),
|
||||
Object::Dict(d) => (d, None),
|
||||
_ => return false,
|
||||
};
|
||||
let drawn = match cat {
|
||||
b"XObject" => is_name(doc, &dict, b"Subtype", b"Form"),
|
||||
b"Font" => is_name(doc, &dict, b"Subtype", b"Type3"),
|
||||
b"XObject" => is_name(doc, dict, b"Subtype", b"Form"),
|
||||
b"Font" => is_name(doc, dict, b"Subtype", b"Type3"),
|
||||
// A shading pattern has no content of its own.
|
||||
_ => int_of(doc, &dict, b"PatternType") == Some(1),
|
||||
_ => int_of(doc, dict, b"PatternType") == Some(1),
|
||||
};
|
||||
drawn && dict.get(b"Resources").is_none()
|
||||
drawn && dict.get(b"Resources").is_none() && (cat == b"Font" || stream.is_none_or(|s| names_resources(doc, s, budget)))
|
||||
}
|
||||
|
||||
/// Whether content stream `s` names a resource (or can't be read within `budget` to tell): device
|
||||
/// colour spaces are not resources; an inline image counts, as its colour space may be one.
|
||||
fn names_resources(doc: &Document, s: &pdfcraft_cos::Stream, budget: &mut DecodeBudget) -> bool {
|
||||
let Ok(data) = budget.decode_strict(doc, s) else { return true };
|
||||
let parsed = parse(&data);
|
||||
parsed.skipped > 0
|
||||
|| parsed.ops.iter().any(|op| match resource_operand(op) {
|
||||
Ok(None) => op.inline.is_some(),
|
||||
Ok(Some((b"ColorSpace", i))) => {
|
||||
!matches!(op.operands.get(i), Some(Object::Name(n)) if [&b"DeviceGray"[..], b"DeviceRGB", b"DeviceCMYK", b"Pattern"].contains(&n.as_slice()))
|
||||
}
|
||||
_ => true,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `ops` (drawn with `resources`) blend with something other than Normal, which counts
|
||||
|
||||
@@ -457,6 +457,12 @@ pub enum Edit {
|
||||
objects: Vec<ObjectTarget>,
|
||||
offset: [f64; 2],
|
||||
},
|
||||
/// Delete mixed existing and added objects together (references from one inventory of
|
||||
/// `page`), as one step: what Delete and Cut do to objects selected together.
|
||||
DeleteObjects {
|
||||
page: usize,
|
||||
objects: Vec<ObjectTarget>,
|
||||
},
|
||||
/// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`),
|
||||
/// rewrapped to the box's width.
|
||||
EditTextBlock {
|
||||
@@ -549,6 +555,13 @@ pub enum Edit {
|
||||
content: AddedContent,
|
||||
file: Option<(String, Arc<Vec<u8>>)>,
|
||||
},
|
||||
/// Edit ▸ Paste of objects copied together in Edit a PDF ([`Document::copy_objects`](crate::Document::copy_objects)), in this
|
||||
/// document or another: drawn on top of `page` as they were, moved by `offset` (user space).
|
||||
PasteObjects {
|
||||
page: usize,
|
||||
clip: Arc<crate::ObjectClip>,
|
||||
offset: [f64; 2],
|
||||
},
|
||||
/// Apply redaction marks (all, or those on `pages`): remove what they cover for good.
|
||||
ApplyRedactions {
|
||||
pages: Option<Vec<usize>>,
|
||||
|
||||
@@ -302,9 +302,9 @@ impl Edit {
|
||||
Edit::AddText { .. } => Label::of("Add text"),
|
||||
Edit::AddImage { .. } => Label::of("Add image"),
|
||||
Edit::UpdateContent { .. } => Label::of("Edit content"),
|
||||
Edit::DeleteContent { .. } => Label::of("Delete content"),
|
||||
Edit::DeleteContent { .. } | Edit::DeleteObjects { .. } => Label::of("Delete content"),
|
||||
Edit::ReplaceImage { .. } => Label::of("Replace image"),
|
||||
Edit::PasteContent { .. } => Label::of("Paste"),
|
||||
Edit::PasteContent { .. } | Edit::PasteObjects { .. } => Label::of("Paste"),
|
||||
Edit::ApplyRedactions { .. } => Label::of("Apply redactions"),
|
||||
Edit::ClearRedactions => Label::of("Remove redaction marks"),
|
||||
Edit::RemoveHidden { .. } => Label::of("Remove hidden information"),
|
||||
|
||||
@@ -111,9 +111,11 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
| Edit::AddImage { .. }
|
||||
| Edit::UpdateContent { .. }
|
||||
| Edit::MoveObjects { .. }
|
||||
| Edit::DeleteObjects { .. }
|
||||
| Edit::DeleteContent { .. }
|
||||
| Edit::ReplaceImage { .. }
|
||||
| Edit::PasteContent { .. }
|
||||
| Edit::PasteObjects { .. }
|
||||
| Edit::ClearRedactions
|
||||
| Edit::RemoveHidden { .. }
|
||||
| Edit::ImportData { .. }
|
||||
@@ -729,6 +731,8 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::EnhanceScan { images } => crate::scan_clean::write_back(doc, images)?,
|
||||
Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?,
|
||||
Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?,
|
||||
Edit::DeleteObjects { page, objects } => crate::object_clip::delete(doc, *page, objects, |doc, edit| run_edit(doc, edit, cx))?,
|
||||
Edit::PasteObjects { page, clip, offset } => crate::object_clip::paste(doc, *page, clip, *offset)?,
|
||||
Edit::EditTextBlock { page, block, text, style } => {
|
||||
pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
// Page content, resources and added content/marks only: the page tree, annotations,
|
||||
// fields, links and document metadata are untouched (see `refresh_content`).
|
||||
Edit::MoveObjects { .. }
|
||||
| Edit::DeleteObjects { .. }
|
||||
| Edit::EditTextLine { .. }
|
||||
| Edit::EditTextBlock { .. }
|
||||
| Edit::PrepareText { .. }
|
||||
@@ -90,6 +91,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
| Edit::DeleteContent { .. }
|
||||
| Edit::ReplaceImage { .. }
|
||||
| Edit::PasteContent { .. }
|
||||
| Edit::PasteObjects { .. }
|
||||
| Edit::AddHeaderFooter { .. }
|
||||
| Edit::AddWatermark { .. }
|
||||
| Edit::AddBackground { .. }
|
||||
|
||||
@@ -36,6 +36,7 @@ pub mod js;
|
||||
mod layers;
|
||||
pub mod links;
|
||||
pub mod model3d;
|
||||
mod object_clip;
|
||||
pub mod ocr;
|
||||
mod open;
|
||||
pub mod optimizer;
|
||||
@@ -108,6 +109,7 @@ pub use pdfcraft_edit::{Backdrop, BlockKey, BlockStyle, PageImage, RangeStyle, T
|
||||
pub use pdfcraft_measure as measure;
|
||||
pub use pdfcraft_xfa::Report as XfaLayout;
|
||||
|
||||
pub use object_clip::{ObjectClip, ObjectClipboard};
|
||||
pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
|
||||
|
||||
pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message};
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Edit ▸ Copy, Cut and Paste of objects selected together in Edit a PDF (#5989).
|
||||
//!
|
||||
//! A copy ([`ObjectClip`]) holds what the objects draw and the resources that drawing names
|
||||
//! ([`pdfcraft_edit::copy_objects`]), with a snapshot of the document they came from, so it can
|
||||
//! be pasted into any open document, also after the original changes or closes. Within the same
|
||||
//! document, while the objects it refers to are unchanged, a paste draws them again from the same
|
||||
//! fonts and images; otherwise it first copies them over ([`pdfcraft_organize::import_value`]):
|
||||
//! each once, through cycles, sharing what the target already holds, and within a size limit.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::Object;
|
||||
|
||||
use crate::{DocId, Document, Edit, EditError, EditableObject, ObjectTarget};
|
||||
|
||||
/// The most objects a paste copies into another document.
|
||||
const MAX_IMPORT_OBJECTS: usize = 20_000;
|
||||
/// The most stream data (fonts, images…) a paste copies into another document.
|
||||
const MAX_IMPORT_BYTES: usize = 256 << 20;
|
||||
/// How far each further paste onto the same page steps down and to the right, in points, as
|
||||
/// pasted added content steps ([`pdfcraft_edit::added::PASTE_STEP`]).
|
||||
const STEP: f64 = pdfcraft_edit::added::PASTE_STEP;
|
||||
|
||||
/// Objects copied from one page of a document.
|
||||
#[derive(Clone)]
|
||||
pub struct ObjectClip {
|
||||
copied: pdfcraft_edit::CopiedObjects,
|
||||
/// The document as it was when copied: what the copy's resources refer to.
|
||||
source: pdfcraft_cos::Document,
|
||||
doc: DocId,
|
||||
page: usize,
|
||||
}
|
||||
|
||||
impl PartialEq for ObjectClip {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.copied == other.copied
|
||||
&& self.doc == other.doc
|
||||
&& self.page == other.page
|
||||
&& self.source.source_identity() == other.source.source_identity()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ObjectClip {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ObjectClip").field("doc", &self.doc).field("page", &self.page).field("objects", &self.copied.objects.len()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectClip {
|
||||
/// The objects as they were listed when copied ([`Document::editable_objects`]).
|
||||
pub fn objects(&self) -> &[EditableObject] {
|
||||
&self.copied.objects
|
||||
}
|
||||
|
||||
/// The text of the copied paragraphs and text boxes, one per line.
|
||||
pub fn text(&self) -> String {
|
||||
self.copied.text()
|
||||
}
|
||||
|
||||
/// The union of the objects' boxes, in the user space of the page they came from.
|
||||
pub fn rect(&self) -> [f64; 4] {
|
||||
self.copied.rect
|
||||
}
|
||||
|
||||
/// The document and page the objects were copied from.
|
||||
pub fn source(&self) -> (DocId, usize) {
|
||||
(self.doc, self.page)
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Edit ▸ Copy of `targets` (references from one [`Document::editable_objects`] inventory of
|
||||
/// `page`). Refused when the document doesn't allow copying. Changes nothing.
|
||||
pub fn copy_objects(&self, page: usize, targets: &[ObjectTarget]) -> Result<ObjectClip, EditError> {
|
||||
self.check_copying()?;
|
||||
let editor = self.editor.as_ref().ok_or_else(|| EditError::ReadOnly("the document can't be read".into()))?;
|
||||
let copied = pdfcraft_edit::copy_objects(&editor.cos, page, targets)?;
|
||||
Ok(ObjectClip { copied, source: editor.cos.clone(), doc: self.id, page })
|
||||
}
|
||||
|
||||
/// Where Paste puts `clip` on `page`: the user-space offset that moves it `steps` × 18 pt down
|
||||
/// and to the right as displayed, or, when that leaves nothing of it on the page's crop box,
|
||||
/// the offset that centres it there.
|
||||
pub fn object_paste_offset(&self, page: usize, clip: &ObjectClip, steps: u32) -> Result<[f64; 2], String> {
|
||||
let step = STEP * f64::from(steps);
|
||||
let offset = self.object_move_offset(page, [step, step])?;
|
||||
let p = self.info.pages.get(page).ok_or("the page no longer exists")?;
|
||||
let crop = p.crop.map(f64::from);
|
||||
let r = clip.rect();
|
||||
let moved = [r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]];
|
||||
if moved[0] < crop[2] && moved[2] > crop[0] && moved[1] < crop[3] && moved[3] > crop[1] {
|
||||
return Ok(offset);
|
||||
}
|
||||
let centre = |a: f64, b: f64| (a + b) / 2.0;
|
||||
Ok([centre(crop[0], crop[2]) - centre(r[0], r[2]), centre(crop[1], crop[3]) - centre(r[1], r[3])])
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects copied in an application or a script, and how many times each page has had them
|
||||
/// pasted, which sets how far the next paste there steps ([`Document::object_paste_offset`]).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ObjectClipboard {
|
||||
clip: Arc<ObjectClip>,
|
||||
pastes: Vec<(DocId, usize, u32)>,
|
||||
}
|
||||
|
||||
impl ObjectClipboard {
|
||||
pub fn new(clip: ObjectClip) -> Self {
|
||||
ObjectClipboard { clip: Arc::new(clip), pastes: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn clip(&self) -> &ObjectClip {
|
||||
&self.clip
|
||||
}
|
||||
|
||||
/// The edit that pastes the copy onto `page` of `doc`: where it was, or on the page it came
|
||||
/// from one step down and to the right (as Acrobat offsets a paste onto the same page), and
|
||||
/// one step further for each earlier paste onto that page.
|
||||
pub fn paste(&mut self, doc: &Document, page: usize) -> Result<Edit, String> {
|
||||
let at = self.pastes.iter().position(|(d, p, _)| *d == doc.id && *p == page);
|
||||
let earlier = at.and_then(|i| self.pastes.get(i)).map_or(0, |(_, _, n)| *n);
|
||||
let own = u32::from(self.clip.source() == (doc.id, page));
|
||||
let offset = doc.object_paste_offset(page, &self.clip, earlier.saturating_add(own))?;
|
||||
match at.and_then(|i| self.pastes.get_mut(i)) {
|
||||
Some(entry) => entry.2 = entry.2.saturating_add(1),
|
||||
None => self.pastes.push((doc.id, page, 1)),
|
||||
}
|
||||
Ok(Edit::PasteObjects { page, clip: Arc::clone(&self.clip), offset })
|
||||
}
|
||||
}
|
||||
|
||||
impl Edit {
|
||||
/// The edit that deletes `targets` (indexes into one inventory of `page`) as one undo step:
|
||||
/// what Delete and Cut do to objects selected together. One object is deleted by its own
|
||||
/// edit, several by [`Edit::DeleteObjects`]; none is no edit.
|
||||
pub fn delete_objects(page: usize, mut targets: Vec<ObjectTarget>) -> Option<Edit> {
|
||||
targets.sort();
|
||||
targets.dedup();
|
||||
match targets.as_slice() {
|
||||
[] => None,
|
||||
[t] => Some(delete_one(page, *t)),
|
||||
_ => Some(Edit::DeleteObjects { page, objects: targets }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_one(page: usize, t: ObjectTarget) -> Edit {
|
||||
use crate::ObjectKind;
|
||||
match t.kind {
|
||||
ObjectKind::Text => Edit::EditTextBlock { page, block: t.index, text: String::new(), style: crate::BlockStyle::default() },
|
||||
ObjectKind::Image => Edit::EditPageImage { page, index: t.index, change: crate::ImageEdit::Delete },
|
||||
ObjectKind::Added => Edit::DeleteContent { page, index: t.index },
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Edit::DeleteObjects`], each object by its own edit (`run`). A deletion renumbers what follows
|
||||
/// it in its own inventory, so pictures go from the last to the first, and added content, which
|
||||
/// the other inventories leave out, after them. Deleting a paragraph also removes a copy drawn
|
||||
/// exactly over it (fake bold, a paste onto itself), which renumbers paragraphs before it too: so
|
||||
/// each paragraph is found again by its text and box, and one already gone with another is done.
|
||||
pub(crate) fn delete(
|
||||
doc: &mut pdfcraft_cos::Document,
|
||||
page: usize,
|
||||
objects: &[ObjectTarget],
|
||||
mut run: impl FnMut(&mut pdfcraft_cos::Document, &Edit) -> Result<(), EditError>,
|
||||
) -> Result<(), EditError> {
|
||||
use crate::ObjectKind;
|
||||
let mut targets = objects.to_vec();
|
||||
targets.sort_by_key(|t| (t.kind == ObjectKind::Added, std::cmp::Reverse(t.index)));
|
||||
targets.dedup();
|
||||
let blocks = pdfcraft_edit::text_blocks(doc, page)?;
|
||||
for t in targets {
|
||||
let edit = match t.kind {
|
||||
ObjectKind::Text => {
|
||||
let key = blocks.get(t.index).map(crate::BlockKey::of).ok_or_else(|| {
|
||||
EditError::Edit(pdfcraft_edit::EditError::Invalid("a selected paragraph no longer exists; select the objects again".into()))
|
||||
})?;
|
||||
let Some(block) = key.find(&pdfcraft_edit::text_blocks(doc, page)?, t.index) else { continue };
|
||||
delete_one(page, ObjectTarget { kind: t.kind, index: block })
|
||||
}
|
||||
_ => delete_one(page, t),
|
||||
};
|
||||
run(doc, &edit)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// [`Edit::PasteObjects`]: the copy's resources as `doc` holds them, then the paste.
|
||||
pub(crate) fn paste(doc: &mut pdfcraft_cos::Document, page: usize, clip: &ObjectClip, offset: [f64; 2]) -> Result<(), EditError> {
|
||||
let resources = Object::Dict(clip.copied.resources.clone());
|
||||
let refused =
|
||||
|e: pdfcraft_organize::OrganizeError| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("the copied objects can't be pasted: {e}")));
|
||||
let used = pdfcraft_organize::reachable(&clip.source, &resources, MAX_IMPORT_OBJECTS, MAX_IMPORT_BYTES).map_err(refused)?;
|
||||
// Still the objects the copy was made from: the same document, and each of them unchanged.
|
||||
let same = doc.source_identity() == clip.source.source_identity()
|
||||
&& used.iter().all(|r| {
|
||||
let (here, there) = (doc.get(*r), clip.source.get(*r));
|
||||
Arc::ptr_eq(&here, &there) || here == there
|
||||
});
|
||||
let resources = if same { resources } else { pdfcraft_organize::import_value(doc, &clip.source, &resources).map_err(refused)? };
|
||||
let Object::Dict(resources) = resources else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the copied objects' resources could not be copied".into())));
|
||||
};
|
||||
let copied = pdfcraft_edit::CopiedObjects { resources, ..clip.copied.clone() };
|
||||
pdfcraft_edit::paste_objects(doc, page, &copied, offset)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "object_clip_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,257 @@
|
||||
use super::*;
|
||||
use crate::{ObjectKind, Session};
|
||||
|
||||
/// A PDF of 300 × 300 pages, each drawing its `content` with `resources` and objects `extra`
|
||||
/// (numbered after the pages' content streams).
|
||||
fn pdf(pages: &[(&str, &str)], extra: &[String]) -> Arc<Vec<u8>> {
|
||||
let n = pages.len();
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect();
|
||||
let mut objs = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 300 300] >>", kids.join(" ")),
|
||||
];
|
||||
for (i, (_, resources)) in pages.iter().enumerate() {
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", 3 + n + i));
|
||||
}
|
||||
for (content, _) in pages {
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()));
|
||||
}
|
||||
objs.extend(extra.iter().cloned());
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Arc::new(out)
|
||||
}
|
||||
|
||||
const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
|
||||
const COURIER: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>";
|
||||
/// A 2 × 2 RGB picture of the twelve bytes `data` (ASCII, so the file's lengths are its bytes).
|
||||
fn picture(data: &str) -> String {
|
||||
assert!(data.is_ascii() && data.len() == 12);
|
||||
format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 12 >>\nstream\n{data}\nendstream"
|
||||
)
|
||||
}
|
||||
|
||||
/// A one-page document whose paragraph "Copied" (blue, half opaque, Helvetica /F1 at objects 5)
|
||||
/// and picture /Im0 (object 6) are copied; "Left" is not.
|
||||
fn source() -> Arc<Vec<u8>> {
|
||||
let content = "0 0 1 rg /GS0 gs BT /F1 24 Tf 20 200 Td (Copied) Tj ET q 60 0 0 40 20 100 cm /Im0 Do Q BT /F1 12 Tf 200 40 Td (Left) Tj ET";
|
||||
pdf(
|
||||
&[(content, "<< /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> >>")],
|
||||
&[HELVETICA.into(), picture("\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}")],
|
||||
)
|
||||
}
|
||||
|
||||
/// A one-page document that already draws with Courier as /F1 and has a picture of its own as /Im0.
|
||||
fn target() -> Arc<Vec<u8>> {
|
||||
pdf(
|
||||
&[("BT /F1 10 Tf 250 280 Td (x) Tj ET q 10 0 0 10 280 5 cm /Im0 Do Q", "<< /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >>")],
|
||||
&[COURIER.into(), picture("@@@@@@@@@@@@")],
|
||||
)
|
||||
}
|
||||
|
||||
fn copy_all_but_left(s: &Session, id: DocId) -> ObjectClip {
|
||||
let doc = s.get(id).unwrap();
|
||||
let targets: Vec<_> = doc.editable_objects(0).unwrap().into_iter().filter(|o| o.text.as_deref() != Some("Left")).map(|o| o.target).collect();
|
||||
assert_eq!(targets.len(), 2);
|
||||
doc.copy_objects(0, &targets).unwrap()
|
||||
}
|
||||
|
||||
fn render(s: &Session, id: DocId) -> pdfcraft_render::RenderedPage {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
|
||||
renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
|
||||
}
|
||||
|
||||
/// The pixels in user-space box `r` of a 300 pt page rendered at scale 1.
|
||||
fn pixels(page: &pdfcraft_render::RenderedPage, r: [f64; 4]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
for y in (300.0 - r[3]) as u32..(300.0 - r[1]) as u32 {
|
||||
for x in r[0] as u32..r[2] as u32 {
|
||||
let i = ((y * page.width + x) * 4) as usize;
|
||||
out.extend_from_slice(&page.rgba[i..i + 4]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn images(s: &Session, id: DocId) -> Vec<Option<pdfcraft_cos::ObjRef>> {
|
||||
s.get(id).unwrap().page_images(0).into_iter().map(|i| i.object).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_copied_from_one_document_paste_into_another_and_draw_the_same() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let a = s.open("a.pdf", None, source(), None).unwrap();
|
||||
let b = s.open("b.pdf", None, target(), None).unwrap();
|
||||
let clip = copy_all_but_left(&s, a);
|
||||
assert_eq!(clip.text(), "Copied");
|
||||
let mut clipboard = ObjectClipboard::new(clip);
|
||||
// The source can change, or close: the copy is a snapshot.
|
||||
s.apply(a, Edit::delete_objects(0, vec![ObjectTarget { kind: ObjectKind::Image, index: 0 }]).unwrap()).unwrap();
|
||||
s.close(a);
|
||||
let a = s.open("a.pdf", None, source(), None).unwrap();
|
||||
|
||||
let paste = clipboard.paste(s.get(b).unwrap(), 0).unwrap();
|
||||
assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [0.0, 0.0]), "another document: where it was");
|
||||
s.apply(b, paste).unwrap();
|
||||
assert_eq!(s.get(b).unwrap().can_undo(), Some("Paste"));
|
||||
let area = clipboard.clip().rect().map(|v| v.round());
|
||||
let area = [area[0] - 3.0, area[1] - 3.0, area[2] + 3.0, area[3] + 3.0];
|
||||
let (original, pasted) = (pixels(&render(&s, a), area), pixels(&render(&s, b), area));
|
||||
assert!(original.chunks(4).any(|p| p[2] > 200 && p[0] < 200), "the blue paragraph is drawn");
|
||||
assert_eq!(original, pasted, "the pasted paragraph and picture draw the same pixels");
|
||||
|
||||
// Its own /F1 and /Im0 are left alone; what the copy uses came over under other names.
|
||||
let doc = s.get(b).unwrap();
|
||||
let texts: Vec<_> = doc.editable_objects(0).unwrap().into_iter().filter_map(|o| o.text).collect();
|
||||
assert!(texts.contains(&"Copied".to_string()) && !texts.contains(&"Left".to_string()), "{texts:?}");
|
||||
let pictures = images(&s, b);
|
||||
assert_eq!(pictures.len(), 2);
|
||||
assert_ne!(pictures[0], pictures[1]);
|
||||
|
||||
// A second paste steps down and to the right, and shares the fonts and pictures the first
|
||||
// brought instead of copying them again.
|
||||
let paste = clipboard.paste(s.get(b).unwrap(), 0).unwrap();
|
||||
assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [18.0, -18.0]), "{paste:?}");
|
||||
s.apply(b, paste).unwrap();
|
||||
let pictures = images(&s, b);
|
||||
assert_eq!(pictures.len(), 3);
|
||||
assert_eq!(pictures[1], pictures[2], "one picture object for both pastes");
|
||||
|
||||
// Saved and reopened, the pastes are there; undone, the page is as it was.
|
||||
let saved = s.save_bytes(b).unwrap();
|
||||
let mut again = Session::new();
|
||||
let reopened = again.open("b.pdf", None, saved, None).unwrap();
|
||||
assert_eq!(again.get(reopened).unwrap().page_images(0).len(), 3);
|
||||
s.undo(b).unwrap();
|
||||
s.undo(b).unwrap();
|
||||
assert_eq!(images(&s, b).len(), 1);
|
||||
assert_eq!(s.get(b).unwrap().can_undo(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paste_onto_the_page_it_came_from_steps_and_draws_from_the_same_objects() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let a = s.open("a.pdf", None, source(), None).unwrap();
|
||||
let mut clipboard = ObjectClipboard::new(copy_all_but_left(&s, a));
|
||||
let paste = clipboard.paste(s.get(a).unwrap(), 0).unwrap();
|
||||
assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [18.0, -18.0]), "one step down and right: {paste:?}");
|
||||
s.apply(a, paste).unwrap();
|
||||
let pictures = images(&s, a);
|
||||
assert_eq!(pictures.len(), 2);
|
||||
assert_eq!(pictures[0], pictures[1], "the same picture object, not a copy");
|
||||
// Far off the page (a copy from a larger page), a paste is centred instead.
|
||||
let doc = s.get(a).unwrap();
|
||||
let mut far = clipboard.clip().clone();
|
||||
far.copied.rect = [1000.0, 1000.0, 1100.0, 1050.0];
|
||||
assert_eq!(doc.object_paste_offset(0, &far, 0).unwrap(), [150.0 - 1050.0, 150.0 - 1025.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_into_another_document_follows_cycles_and_refuses_past_its_limit() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
// A font that refers to itself and to an object that refers back.
|
||||
let cyclic = pdf(
|
||||
&[("BT /F1 20 Tf 20 200 Td (Loop) Tj ET", "<< /Font << /F1 5 0 R >> >>")],
|
||||
&[
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Self 5 0 R /Next [6 0 R] >>".into(),
|
||||
"<< /Back 5 0 R /Again 6 0 R >>".into(),
|
||||
],
|
||||
);
|
||||
let a = s.open("cyclic.pdf", None, cyclic, None).unwrap();
|
||||
let b = s.open("b.pdf", None, target(), None).unwrap();
|
||||
let all: Vec<_> = s.get(a).unwrap().editable_objects(0).unwrap().into_iter().map(|o| o.target).collect();
|
||||
let mut clipboard = ObjectClipboard::new(s.get(a).unwrap().copy_objects(0, &all).unwrap());
|
||||
s.apply(b, clipboard.paste(s.get(b).unwrap(), 0).unwrap()).unwrap();
|
||||
assert!(s.get(b).unwrap().editable_objects(0).unwrap().iter().any(|o| o.text.as_deref() == Some("Loop")));
|
||||
|
||||
// An ExtGState that drags in more objects than a paste copies is refused with a reason.
|
||||
let many = 20_001;
|
||||
let refs: Vec<String> = (0..many).map(|i| format!("{} 0 R", 6 + i)).collect();
|
||||
let mut extra = vec![format!("<< /ca 0.5 /Junk [{}] >>", refs.join(" "))];
|
||||
extra.extend((0..many).map(|i| format!("<< /N {i} >>")));
|
||||
let resources = format!("<< /Font << /F1 {HELVETICA} >> /ExtGState << /GS0 5 0 R >> >>");
|
||||
let big = pdf(&[("/GS0 gs BT /F1 20 Tf 20 200 Td (Big) Tj ET", &resources)], &extra);
|
||||
let c = s.open("big.pdf", None, big, None).unwrap();
|
||||
let all: Vec<_> = s.get(c).unwrap().editable_objects(0).unwrap().into_iter().map(|o| o.target).collect();
|
||||
assert_eq!(all.len(), 1);
|
||||
let mut clipboard = ObjectClipboard::new(s.get(c).unwrap().copy_objects(0, &all).unwrap());
|
||||
let before = s.get(b).unwrap().can_undo().map(str::to_string);
|
||||
let err = s.apply(b, clipboard.paste(s.get(b).unwrap(), 0).unwrap()).unwrap_err().to_string();
|
||||
assert!(err.contains("copy fewer objects"), "{err}");
|
||||
assert_eq!(s.get(b).unwrap().can_undo().map(str::to_string), before, "a refused paste changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_refuses_a_stale_selection_and_deleting_several_objects_is_one_step() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let a = s.open("a.pdf", None, source(), None).unwrap();
|
||||
let doc = s.get(a).unwrap();
|
||||
assert!(doc.copy_objects(0, &[ObjectTarget { kind: ObjectKind::Text, index: 7 }]).is_err());
|
||||
assert!(doc.copy_objects(3, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }]).is_err());
|
||||
let delete = Edit::delete_objects(0, vec![ObjectTarget { kind: ObjectKind::Text, index: 0 }, ObjectTarget { kind: ObjectKind::Image, index: 0 }]);
|
||||
assert!(matches!(delete, Some(Edit::DeleteObjects { .. })));
|
||||
assert!(Edit::delete_objects(0, Vec::new()).is_none());
|
||||
s.apply(a, delete.unwrap()).unwrap();
|
||||
assert_eq!(s.get(a).unwrap().can_undo(), Some("Delete content"));
|
||||
assert!(s.get(a).unwrap().editable_objects(0).unwrap().iter().all(|o| o.text.as_deref() == Some("Left")));
|
||||
}
|
||||
|
||||
/// Two pastes of a paragraph and a picture exactly over each other: deleting a paragraph also
|
||||
/// removes a copy drawn exactly over it, so deleting all four objects together found the second
|
||||
/// paragraph's number gone and refused the whole delete. Both copies go, in one step.
|
||||
#[test]
|
||||
fn objects_pasted_exactly_over_each_other_delete_together_in_one_step() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let a = s.open("a.pdf", None, source(), None).unwrap();
|
||||
let blank = s.create_blank(300.0, 300.0, 1).unwrap();
|
||||
let b = s.open_new("b.pdf", blank).unwrap();
|
||||
let mut clipboard = ObjectClipboard::new(copy_all_but_left(&s, a));
|
||||
let Edit::PasteObjects { clip, .. } = clipboard.paste(s.get(b).unwrap(), 0).unwrap() else { panic!("a paste") };
|
||||
for _ in 0..2 {
|
||||
s.apply(b, Edit::PasteObjects { page: 0, clip: Arc::clone(&clip), offset: [0.0, 0.0] }).unwrap();
|
||||
}
|
||||
let pasted = s.get(b).unwrap().editable_objects(0).unwrap();
|
||||
assert_eq!(pasted.iter().filter(|o| o.text.as_deref() == Some("Copied")).count(), 2, "{pasted:?}");
|
||||
assert_eq!(pasted.len(), 4);
|
||||
let delete = Edit::delete_objects(0, pasted.iter().map(|o| o.target).collect()).unwrap();
|
||||
s.apply(b, delete).unwrap();
|
||||
assert!(s.get(b).unwrap().editable_objects(0).unwrap().is_empty());
|
||||
assert_eq!(s.get(b).unwrap().can_undo(), Some("Delete content"));
|
||||
s.undo(b).unwrap();
|
||||
assert_eq!(s.get(b).unwrap().editable_objects(0).unwrap(), pasted, "one undo brings both copies back");
|
||||
}
|
||||
|
||||
/// Deleting renumbers what follows in the same inventory, so paragraphs and pictures go from
|
||||
/// the last to the first, and added content (outside those inventories) after them.
|
||||
#[test]
|
||||
fn several_objects_delete_last_first() {
|
||||
let content =
|
||||
"BT /F1 12 Tf 20 250 Td (A) Tj ET BT /F1 12 Tf 20 180 Td (B) Tj ET BT /F1 12 Tf 20 110 Td (C) Tj ET BT /F1 12 Tf 20 40 Td (D) Tj ET";
|
||||
let mut doc = pdfcraft_cos::Document::open(pdf(&[(content, "<< /Font << /F1 5 0 R >> >>")], &[HELVETICA.into()])).unwrap();
|
||||
let t = |kind, index| ObjectTarget { kind, index };
|
||||
let targets = [t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0), t(ObjectKind::Text, 3), t(ObjectKind::Added, 2)];
|
||||
let mut order = Vec::new();
|
||||
delete(&mut doc, 0, &targets, |_, e| {
|
||||
order.push(match e {
|
||||
Edit::EditTextBlock { block, text, .. } if text.is_empty() => ('t', *block),
|
||||
Edit::EditPageImage { index, change: crate::ImageEdit::Delete, .. } => ('i', *index),
|
||||
Edit::DeleteContent { index, .. } => ('a', *index),
|
||||
other => panic!("unexpected {other:?}"),
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(order, [('t', 3), ('t', 1), ('i', 0), ('a', 2), ('a', 0)]);
|
||||
}
|
||||
@@ -434,7 +434,7 @@ impl Session {
|
||||
| Edit::ReplaceImage { .. }
|
||||
| Edit::PasteContent { .. }
|
||||
| Edit::EditPageImage { .. }
|
||||
) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
|
||||
) || matches!(e, Edit::MoveObjects { objects, .. } | Edit::DeleteObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
|
||||
});
|
||||
let touches_marks = edit.is_none_or(|e| {
|
||||
matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. })
|
||||
|
||||
@@ -480,6 +480,64 @@ pub(crate) fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRe
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The objects `value` (of `src`) refers to, directly or through others, each once whatever the
|
||||
/// cycles: what [`import_value`] copies. Refused past `max_objects` objects or `max_bytes` bytes
|
||||
/// of stream data, with a message that says how much there is.
|
||||
pub fn reachable(src: &Document, value: &Object, max_objects: usize, max_bytes: usize) -> Result<Vec<ObjRef>, OrganizeError> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut found = Vec::new();
|
||||
let mut bytes = 0usize;
|
||||
// The values still to look into: owned, or an object of `src`.
|
||||
let mut pending: Vec<std::sync::Arc<Object>> = vec![std::sync::Arc::new(value.clone())];
|
||||
while let Some(next) = pending.pop() {
|
||||
let mut refs = Vec::new();
|
||||
let mut inside: Vec<&Object> = vec![&next];
|
||||
while let Some(o) = inside.pop() {
|
||||
match o {
|
||||
Object::Ref(r) => refs.push(*r),
|
||||
Object::Array(a) => inside.extend(a.iter()),
|
||||
Object::Dict(d) => inside.extend(d.iter().map(|(_, v)| v)),
|
||||
Object::Stream(s) => {
|
||||
bytes = bytes.saturating_add(s.raw.len());
|
||||
inside.extend(s.dict.iter().map(|(_, v)| v));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for r in refs {
|
||||
if seen.insert(r) {
|
||||
found.push(r);
|
||||
pending.push(src.get(r));
|
||||
}
|
||||
}
|
||||
if found.len() > max_objects || bytes > max_bytes {
|
||||
return Err(OrganizeError::Invalid(format!(
|
||||
"it uses more than {max_objects} objects or {} MB of fonts, images and other data; copy fewer objects at a time",
|
||||
max_bytes >> 20
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Copy `value` from `src` into `dst` with every object it refers to, each once (cycles end), as
|
||||
/// Insert Pages copies a page's resources; what equals an object already in `dst` (the same font
|
||||
/// or image) is shared rather than stored twice. Returns `value` as `dst` refers to it. A chain
|
||||
/// nested more than 256 levels deep is refused; [`reachable`] bounds the size first.
|
||||
pub fn import_value(dst: &mut Document, src: &Document, value: &Object) -> Result<Object, OrganizeError> {
|
||||
let before: HashSet<u32> = dst.object_numbers().into_iter().collect();
|
||||
let mut copier = Copier::new(src, HashMap::new(), HashMap::new());
|
||||
let copied = copier.copy_value(dst, value, None)?;
|
||||
// Held in an array object while duplicates merge (an array is never a resource that merges
|
||||
// itself away), so the references in it follow them.
|
||||
let holder = dst.add(Object::Array(vec![copied]));
|
||||
let created = added_objects(dst, &before);
|
||||
crate::dedupe::dedupe_resources(dst, &created, true);
|
||||
let held = dst.get(holder).as_array().and_then(|a| a.first().cloned()).unwrap_or(Object::Null);
|
||||
dst.free(holder);
|
||||
Ok(held)
|
||||
}
|
||||
|
||||
/// Page `page` of `src` as a form XObject in `dst` (its content and resources, no annotations),
|
||||
/// for backgrounds and watermarks taken from a PDF. The form's `/BBox` is the page's crop box
|
||||
/// and its `/Matrix` undoes the page rotation, so it draws upright as displayed. Returns the
|
||||
|
||||
@@ -33,8 +33,8 @@ pub use comments::import_comments;
|
||||
pub use dedupe::dedupe_resources;
|
||||
pub use import::{
|
||||
CombineOutcome, Combiner, FieldConflicts, ImportOutcome, Run, SplitBy, combine, combine_field_conflicts, combine_grouped, combine_grouped_with,
|
||||
combine_selected, combine_selected_with, combine_with, extract_pages, field_name_conflicts, import_pages, import_pages_with, insert_file_pages,
|
||||
page_as_form, split, split_ranges,
|
||||
combine_selected, combine_selected_with, combine_with, extract_pages, field_name_conflicts, import_pages, import_pages_with, import_value,
|
||||
insert_file_pages, page_as_form, reachable, split, split_ranges,
|
||||
};
|
||||
pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges};
|
||||
pub use outline::{
|
||||
|
||||
@@ -18,7 +18,7 @@ mod links;
|
||||
mod prefs;
|
||||
mod window;
|
||||
|
||||
pub(crate) use clipboard::{copy_refused, print_refused};
|
||||
pub(crate) use clipboard::{ObjectClipMark, copy_refused, print_refused};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use files::same_file;
|
||||
use frame::migrate_panel_state;
|
||||
@@ -245,6 +245,8 @@ impl PdfCraftApp {
|
||||
progress_notice: None,
|
||||
page_clipboard: None,
|
||||
comment_clipboard: None,
|
||||
object_clipboard: None,
|
||||
object_clip_mark: Default::default(),
|
||||
grid_drop: None,
|
||||
last_snapshot: None,
|
||||
last_copied_image: None,
|
||||
|
||||
@@ -15,6 +15,24 @@ enum ClipboardPaste {
|
||||
Image(std::sync::Arc<egui::ColorImage>),
|
||||
}
|
||||
|
||||
/// What objects copied in Edit a PDF put on the system clipboard for other applications, so ⌘V
|
||||
/// can tell whether it still holds that copy or something copied since: their text, or else the
|
||||
/// first picture's pixels (a hash of them), or nothing.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) enum ObjectClipMark {
|
||||
Text(String),
|
||||
Picture(u64),
|
||||
#[default]
|
||||
None,
|
||||
}
|
||||
|
||||
fn picture_hash(width: usize, height: usize, rgba: &[u8]) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(width, height, rgba).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Whether text and images may be copied out of document `id`; says why not when they may
|
||||
/// not. Reading aloud and accessibility tools don't ask (assistive technology may read
|
||||
@@ -53,8 +71,14 @@ impl PdfCraftApp {
|
||||
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X)));
|
||||
let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V)));
|
||||
|
||||
// Objects selected together in Edit a PDF take ⌘C and ⌘X.
|
||||
let objects = (copy || cut) && self.quick_tool == QuickTool::EditText && self.views.get(i).is_some_and(|v| v.objects.count() > 0);
|
||||
if objects {
|
||||
self.copy_objects(i, ctx, cut);
|
||||
}
|
||||
let text_selected = copy && self.views.get(i).and_then(|v| v.selected_text()).is_some_and(|t| !t.is_empty());
|
||||
if copy
|
||||
&& !objects
|
||||
&& (text_selected || !self.copy_selected_comments(i))
|
||||
&& !self.copy_selected_text(i, ctx)
|
||||
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
|
||||
@@ -64,12 +88,18 @@ impl PdfCraftApp {
|
||||
self.copy_page_image(sel.page, sel.index);
|
||||
}
|
||||
if cut
|
||||
&& !objects
|
||||
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
|
||||
&& let Some(id) = self.views.get(i).map(|v| v.id)
|
||||
&& self.may_copy(id)
|
||||
{
|
||||
self.cut_page_image(sel.page, sel.index);
|
||||
}
|
||||
if (paste || !pastes.is_empty()) && self.quick_tool == QuickTool::EditText && self.object_clipboard.is_some() && self.pastes_objects(&pastes)
|
||||
{
|
||||
self.paste_objects(i);
|
||||
return;
|
||||
}
|
||||
if !pastes.is_empty() {
|
||||
self.receive_pastes(i, pastes, true);
|
||||
return;
|
||||
@@ -92,6 +122,90 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// ⌘C / ⌘X in Edit a PDF with objects selected together: copy them for ⌘V in any open
|
||||
/// document, and put their text (or else their first picture) on the system clipboard for
|
||||
/// other applications. A cut then deletes them, as one undo step.
|
||||
pub(crate) fn copy_objects(&mut self, i: usize, ctx: &egui::Context, cut: bool) -> bool {
|
||||
let Some((id, Some((page, targets)))) = self.views.get(i).map(|v| (v.id, v.objects.selection())) else { return false };
|
||||
if !self.may_copy(id) {
|
||||
return false;
|
||||
}
|
||||
let Some(doc) = self.session.get(id) else { return false };
|
||||
if cut && !doc.allows_modification() {
|
||||
self.notify_error(tl!("The document's security settings don't allow this change"));
|
||||
return false;
|
||||
}
|
||||
let clip = match doc.copy_objects(page, &targets) {
|
||||
Ok(clip) => clip,
|
||||
Err(e) => {
|
||||
self.notify_error(e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// A newer copy wins over a whole-document copy still reading its pages.
|
||||
self.document_copy = None;
|
||||
let text = clip.text();
|
||||
let picture =
|
||||
clip.objects().iter().find(|o| o.target.kind == pdfcraft_engine::ObjectKind::Image && o.content_type == "image").map(|o| o.target.index);
|
||||
self.object_clip_mark = if !text.is_empty() {
|
||||
ctx.copy_text(text.clone());
|
||||
ObjectClipMark::Text(text)
|
||||
} else if let Some(index) = picture.filter(|_| self.system_clipboard && cfg!(not(target_arch = "wasm32")))
|
||||
&& self.copy_page_image(page, index)
|
||||
&& let Some((w, h, rgba)) = &self.last_copied_image
|
||||
{
|
||||
ObjectClipMark::Picture(picture_hash(*w as usize, *h as usize, rgba))
|
||||
} else {
|
||||
ObjectClipMark::None
|
||||
};
|
||||
self.object_clipboard = Some(pdfcraft_engine::ObjectClipboard::new(clip));
|
||||
if cut && let Some(delete) = pdfcraft_engine::Edit::delete_objects(page, targets) {
|
||||
self.apply_edit(delete);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether ⌘V's `pastes` (what the system clipboard gave, if anything) are the objects
|
||||
/// copied in Edit a PDF, rather than something copied since, here or in another application.
|
||||
fn pastes_objects(&self, pastes: &[ClipboardPaste]) -> bool {
|
||||
pastes.iter().all(|paste| match (paste, &self.object_clip_mark) {
|
||||
(ClipboardPaste::Text(text), ObjectClipMark::Text(copied)) => text.replace("\r\n", "\n").trim_end() == copied.trim_end(),
|
||||
(ClipboardPaste::Text(text), ObjectClipMark::None) => !pdfcraft_engine::is_xfdf_comments(text),
|
||||
(ClipboardPaste::Image(image), ObjectClipMark::Picture(hash)) => {
|
||||
let rgba: Vec<u8> = image.pixels.iter().flat_map(egui::Color32::to_srgba_unmultiplied).collect();
|
||||
picture_hash(image.size[0], image.size[1], &rgba) == *hash
|
||||
}
|
||||
(ClipboardPaste::Image(_), ObjectClipMark::None) => true,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// ⌘V in Edit a PDF: the copied objects onto the current page of tab `i`, as page content
|
||||
/// that draws the same (one undo step), and selected.
|
||||
pub(crate) fn paste_objects(&mut self, i: usize) -> bool {
|
||||
let Some((id, page)) = self.views.get(i).map(|v| (v.id, v.current)) else { return false };
|
||||
let (Some(doc), Some(clipboard)) = (self.session.get(id), self.object_clipboard.as_mut()) else { return false };
|
||||
if !doc.allows_modification() {
|
||||
self.notify_error(tl!("The document's security settings don't allow this change"));
|
||||
return false;
|
||||
}
|
||||
let before = doc.editable_objects(page).unwrap_or_default();
|
||||
let edit = match clipboard.paste(doc, page) {
|
||||
Ok(edit) => edit,
|
||||
Err(e) => {
|
||||
self.notify_error(e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if !self.apply_edit(edit) {
|
||||
return false;
|
||||
}
|
||||
if let (Some(Ok(after)), Some(view)) = (self.session.get(id).map(|d| d.editable_objects(page)), self.views.get_mut(i)) {
|
||||
view.objects.select_pasted(page, &before, after);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `on_canvas`: the paste is the canvas's (no text field, dialog or page grid has it), so
|
||||
/// pictures and copied comments land on the page.
|
||||
fn receive_pastes(&mut self, view: usize, pastes: Vec<ClipboardPaste>, on_canvas: bool) {
|
||||
|
||||
@@ -50,8 +50,13 @@ impl PdfCraftApp {
|
||||
&& (pages_owns_keyboard || !ctx.egui_wants_keyboard_input())
|
||||
&& ctx.input_mut(|input| input.consume_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, Key::A)))
|
||||
{
|
||||
let view = &self.views[i];
|
||||
// A paragraph or text box open for typing keeps ⌘A for its own text.
|
||||
let edit_objects = self.quick_tool == QuickTool::EditText && view.line_editor.is_none() && view.content.draft.is_none();
|
||||
if pages_owns_keyboard {
|
||||
self.views[i].select_all_pages();
|
||||
} else if let Some(doc) = self.session.get(view.id).filter(|d| edit_objects && d.allows_modification()) {
|
||||
object_ui::select_all(&mut self.views[i], doc);
|
||||
} else if self.is_preparing() {
|
||||
if let Some(doc) = self.session.get(self.views[i].id) {
|
||||
prepare::select_all(&mut self.views[i], &doc.form);
|
||||
|
||||
@@ -917,6 +917,7 @@ fn re_resolve_image_selection(
|
||||
fn content_page(edit: &Edit) -> Option<usize> {
|
||||
match edit {
|
||||
Edit::MoveObjects { page, .. }
|
||||
| Edit::DeleteObjects { page, .. }
|
||||
| Edit::EditTextLine { page, .. }
|
||||
| Edit::EditTextBlock { page, .. }
|
||||
| Edit::EditPageImage { page, .. }
|
||||
@@ -924,7 +925,8 @@ fn content_page(edit: &Edit) -> Option<usize> {
|
||||
| Edit::AddImage { page, .. }
|
||||
| Edit::UpdateContent { page, .. }
|
||||
| Edit::DeleteContent { page, .. }
|
||||
| Edit::ReplaceImage { page, .. } => Some(*page),
|
||||
| Edit::ReplaceImage { page, .. }
|
||||
| Edit::PasteObjects { page, .. } => Some(*page),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,6 +603,10 @@ pub struct PdfCraftApp {
|
||||
pub page_clipboard: Option<PageClip>,
|
||||
/// Copied comments (XFDF), to paste into this or another document (M14).
|
||||
pub comment_clipboard: Option<CommentClip>,
|
||||
/// Objects copied together in Edit a PDF, to paste into this or another document (#5989).
|
||||
pub object_clipboard: Option<pdfcraft_engine::ObjectClipboard>,
|
||||
/// What copying them put on the system clipboard (see [`app::ObjectClipMark`]).
|
||||
pub(crate) object_clip_mark: app::ObjectClipMark,
|
||||
/// Files dropped on the page grid, waiting for the pointer to say which gap they go to.
|
||||
grid_drop: Option<GridDrop>,
|
||||
/// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the
|
||||
|
||||
@@ -475,6 +475,10 @@ impl PdfCraftApp {
|
||||
let enabled_id = |id: &str| commands::command(id).is_some_and(|s| self.command_enabled(s));
|
||||
let index = param_index(command);
|
||||
let text = ctx.text_edit_focused() && matches!(command.id.as_str(), "edit.undo" | "edit.redo" | "edit.copy" | CUT | PASTE | SELECT_ALL);
|
||||
// Objects selected together in Edit a PDF are what Copy and Cut take, and Paste brings
|
||||
// back objects copied there.
|
||||
let edit_objects = self.quick_tool == crate::QuickTool::EditText && !organize;
|
||||
let doc = self.active_ids().and_then(|(_, id)| self.session.get(id));
|
||||
let enabled = text
|
||||
|| match command.id.as_str() {
|
||||
NO_RECENT => false,
|
||||
@@ -487,6 +491,10 @@ impl PdfCraftApp {
|
||||
"edit.copy" if view.is_some_and(|v| v.picked_image.is_some()) => {
|
||||
self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_copying())
|
||||
}
|
||||
"edit.copy" | CUT if edit_objects && view.is_some_and(|v| v.objects.count() > 0) => {
|
||||
doc.is_some_and(|d| d.allows_copying() && (command.id != CUT || d.allows_modification()))
|
||||
}
|
||||
PASTE if edit_objects && self.object_clipboard.is_some() => doc.is_some_and(pdfcraft_engine::Document::allows_modification),
|
||||
CUT => organize && enabled_id("page.cut"),
|
||||
PASTE => organize && self.page_clipboard.is_some() && enabled_id("page.paste"),
|
||||
SELECT_ALL | DELETE => has_doc,
|
||||
|
||||
@@ -48,6 +48,39 @@ impl ObjectSelection {
|
||||
self.selected.len()
|
||||
}
|
||||
|
||||
/// The page and references of the objects selected together, if any: what Copy and Cut take.
|
||||
pub(crate) fn selection(&self) -> Option<(usize, Vec<ObjectTarget>)> {
|
||||
self.page.filter(|_| !self.selected.is_empty()).map(|page| (page, self.selected.iter().map(|o| o.target).collect()))
|
||||
}
|
||||
|
||||
/// Select what a paste added to `page`, as Acrobat selects pasted objects: the objects of
|
||||
/// `after` (the page's inventory with the paste) that `before` (without it) didn't have. They
|
||||
/// are taken at the next `prepare`, which sees the new document generation.
|
||||
pub(crate) fn select_pasted(&mut self, page: usize, before: &[EditableObject], after: Vec<EditableObject>) {
|
||||
let same = |a: &EditableObject, b: &EditableObject| {
|
||||
a.target.kind == b.target.kind
|
||||
&& a.content_type == b.content_type
|
||||
&& a.text == b.text
|
||||
&& a.rect.iter().zip(b.rect).all(|(x, y)| (x - y).abs() < 0.01)
|
||||
};
|
||||
let mut old: Vec<&EditableObject> = before.iter().collect();
|
||||
let added: Vec<_> = after
|
||||
.into_iter()
|
||||
.filter(|o| match old.iter().position(|b| same(b, o)) {
|
||||
Some(i) => {
|
||||
old.swap_remove(i);
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
})
|
||||
.collect();
|
||||
self.clear();
|
||||
if !added.is_empty() && added.len() <= MAX_MOVE_OBJECTS {
|
||||
self.page = Some(page);
|
||||
self.pending = Some(added);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit text's input is blocked: a dialog, the palette, another window or another tab has it.
|
||||
pub(crate) fn input_blocked(&self) -> bool {
|
||||
self.input_blocked
|
||||
@@ -188,6 +221,25 @@ fn leave_single(view: &mut DocView) {
|
||||
view.block_drag = None;
|
||||
}
|
||||
|
||||
/// ⌘A in Edit a PDF: every object on the current page selected together, as Acrobat selects
|
||||
/// them (paragraphs, pictures and added content), instead of the page's text. A page whose
|
||||
/// objects can't be selected together, or has too many, says so and keeps what was selected.
|
||||
pub(crate) fn select_all(view: &mut DocView, doc: &Document) {
|
||||
let page = view.current;
|
||||
match view.objects.inventory(doc, page) {
|
||||
Ok(objects) if objects.len() > MAX_MOVE_OBJECTS => view.objects.notice = Some(tl!("Select at most 1000 objects at a time.").into()),
|
||||
Ok(objects) => {
|
||||
view.objects.clear();
|
||||
if !objects.is_empty() {
|
||||
leave_single(view);
|
||||
view.objects.page = Some(page);
|
||||
view.objects.selected = objects;
|
||||
}
|
||||
}
|
||||
Err(_) => view.objects.notice = Some(tl!("This page contains objects that cannot be selected together.").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// What of the objects lies under `p` on `page`: the objects selected together (a drag moves
|
||||
/// them), or the frame around the paragraph open for typing (`open`: its box on screen), which
|
||||
/// takes hold of the box as in Acrobat: a click selects it, a drag moves it, its text keeps the
|
||||
@@ -288,7 +340,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, press: PagePress, view: &mut D
|
||||
let deleted =
|
||||
ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) || i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace));
|
||||
if deleted {
|
||||
view.pending_edit = delete_edit(page, view.objects.selected.iter().map(|o| o.target).collect());
|
||||
view.pending_edit = Edit::delete_objects(page, view.objects.selected.iter().map(|o| o.target).collect());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -503,30 +555,7 @@ pub(crate) fn delete_keys(ctx: &egui::Context, view: &mut DocView) {
|
||||
}
|
||||
// The selection stays until the edit applies: `prepare` clears it when the document changes,
|
||||
// and a refused delete (text used as a clipping path) leaves it selected.
|
||||
view.pending_edit = delete_edit(page, view.objects.selected.iter().map(|o| o.target).collect());
|
||||
}
|
||||
|
||||
/// The edit that deletes `targets` (indexes into one inventory of `page`) as one undo step.
|
||||
/// Each deletion renumbers only what follows it in its own inventory, so paragraphs and images
|
||||
/// go from the last to the first, and added content, which the source inventories leave out,
|
||||
/// after them.
|
||||
fn delete_edit(page: usize, mut targets: Vec<ObjectTarget>) -> Option<Edit> {
|
||||
targets.sort_by_key(|t| (t.kind == ObjectKind::Added, std::cmp::Reverse(t.index)));
|
||||
targets.dedup();
|
||||
let mut edits: Vec<Edit> = targets
|
||||
.into_iter()
|
||||
.map(|t| match t.kind {
|
||||
ObjectKind::Text => Edit::EditTextBlock { page, block: t.index, text: String::new(), style: pdfcraft_engine::BlockStyle::default() },
|
||||
ObjectKind::Image => Edit::EditPageImage { page, index: t.index, change: pdfcraft_engine::ImageEdit::Delete },
|
||||
ObjectKind::Added => Edit::DeleteContent { page, index: t.index },
|
||||
})
|
||||
.collect();
|
||||
if edits.len() > 1 {
|
||||
// "Delete content" is an edit label already, so the history reads the same in every language.
|
||||
Some(Edit::Batch { label: "Delete content".into(), edits })
|
||||
} else {
|
||||
edits.pop()
|
||||
}
|
||||
view.pending_edit = Edit::delete_objects(page, view.objects.selected.iter().map(|o| o.target).collect());
|
||||
}
|
||||
|
||||
pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, selection: &ObjectSelection) {
|
||||
@@ -569,32 +598,20 @@ mod tests {
|
||||
ObjectTarget { kind, index }
|
||||
}
|
||||
|
||||
/// Deleting renumbers what follows in the same inventory, so the batch takes each inventory
|
||||
/// from its last item to its first, and added content (outside the source inventories) last.
|
||||
/// Several objects are one step, each object once (the engine orders the deletions).
|
||||
#[test]
|
||||
fn several_objects_delete_last_first_in_one_step() {
|
||||
let targets = vec![t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0), t(ObjectKind::Text, 3), t(ObjectKind::Added, 2)];
|
||||
let Some(Edit::Batch { label, edits }) = delete_edit(0, targets) else { panic!("several objects make one batch") };
|
||||
assert_eq!(label, "Delete content");
|
||||
let order: Vec<(char, usize)> = edits
|
||||
.iter()
|
||||
.map(|e| match e {
|
||||
Edit::EditTextBlock { block, text, .. } => {
|
||||
assert!(text.is_empty());
|
||||
('t', *block)
|
||||
}
|
||||
Edit::EditPageImage { index, change: pdfcraft_engine::ImageEdit::Delete, .. } => ('i', *index),
|
||||
Edit::DeleteContent { index, .. } => ('a', *index),
|
||||
other => panic!("unexpected {}", other.label().wording),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(order, [('t', 3), ('t', 1), ('i', 0), ('a', 2), ('a', 0)]);
|
||||
fn several_objects_delete_in_one_step() {
|
||||
let targets = vec![t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0), t(ObjectKind::Text, 1)];
|
||||
let edit = Edit::delete_objects(0, targets).unwrap();
|
||||
let once = [t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0)];
|
||||
assert!(matches!(&edit, Edit::DeleteObjects { page: 0, objects } if *objects == once), "{edit:?}");
|
||||
assert_eq!(edit.label().wording, "Delete content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_object_deletes_with_its_own_edit_and_none_with_nothing() {
|
||||
assert!(matches!(delete_edit(2, vec![t(ObjectKind::Image, 4)]), Some(Edit::EditPageImage { page: 2, index: 4, .. })));
|
||||
assert!(delete_edit(0, Vec::new()).is_none());
|
||||
assert!(matches!(Edit::delete_objects(2, vec![t(ObjectKind::Image, 4)]), Some(Edit::EditPageImage { page: 2, index: 4, .. })));
|
||||
assert!(Edit::delete_objects(0, Vec::new()).is_none());
|
||||
}
|
||||
|
||||
/// Selecting content objects on a page and pressing Delete must queue one edit that removes
|
||||
|
||||
@@ -504,3 +504,114 @@ fn a_double_click_on_a_grouped_paragraph_opens_it() {
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[0].line_editor.is_some(), "the paragraph is open for typing");
|
||||
}
|
||||
|
||||
fn texts(h: &Harness<'static, PdfCraftApp>, view: usize) -> Vec<String> {
|
||||
let app = h.state();
|
||||
app.session.get(app.views[view].id).unwrap().editable_objects(0).unwrap().into_iter().filter_map(|o| o.text).collect()
|
||||
}
|
||||
|
||||
fn can_undo(h: &Harness<'static, PdfCraftApp>, view: usize) -> Option<String> {
|
||||
let app = h.state();
|
||||
app.session.get(app.views[view].id).unwrap().can_undo().map(str::to_string)
|
||||
}
|
||||
|
||||
fn keys(h: &mut Harness<'static, PdfCraftApp>, modifiers: Modifiers, key: Key) {
|
||||
h.key_press_modifiers(modifiers, key);
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// ⌘A in Edit a PDF selects every object on the page, as Acrobat does: paragraphs, artwork and
|
||||
/// added content. The arrow keys then move them all and Delete removes them all, each as one step.
|
||||
#[test]
|
||||
fn command_a_selects_every_object_on_the_page_to_move_and_delete_together() {
|
||||
let mut h = harness();
|
||||
let before = objects(&h);
|
||||
assert_eq!(before.len(), 4);
|
||||
keys(&mut h, Modifiers::COMMAND, Key::A);
|
||||
h.get_by_label("4 objects selected");
|
||||
assert!(h.state().views[0].selected_text().is_none_or(|t| t.is_empty()), "objects, not the page's text");
|
||||
keys(&mut h, Modifiers::SHIFT, Key::ArrowRight);
|
||||
assert_eq!(can_undo(&h, 0).as_deref(), Some("Move objects"));
|
||||
for (old, new) in before.iter().zip(objects(&h)) {
|
||||
assert!((new.rect[0] - old.rect[0] - 10.0).abs() < 0.01, "{old:?} -> {new:?}");
|
||||
}
|
||||
h.get_by_label("4 objects selected");
|
||||
keys(&mut h, Modifiers::NONE, Key::Delete);
|
||||
assert!(objects(&h).is_empty(), "{:?}", objects(&h));
|
||||
assert_eq!(can_undo(&h, 0).as_deref(), Some("Delete content"));
|
||||
keys(&mut h, Modifiers::COMMAND, Key::Z);
|
||||
assert_eq!(objects(&h).len(), 4, "one undo brings every object back");
|
||||
}
|
||||
|
||||
/// In a paragraph open for typing, ⌘A selects its text, as before.
|
||||
#[test]
|
||||
fn command_a_in_an_open_paragraph_selects_its_text_not_the_objects() {
|
||||
let mut h = harness();
|
||||
let p = at(&h, 55.0, 70.0);
|
||||
click(&mut h, p, Modifiers::NONE);
|
||||
assert!(h.state().views[0].line_editor.is_some());
|
||||
keys(&mut h, Modifiers::COMMAND, Key::A);
|
||||
assert!(h.query_all_by_label_contains("objects selected").next().is_none());
|
||||
assert!(h.state().views[0].line_editor.is_some(), "the paragraph stays open");
|
||||
}
|
||||
|
||||
/// ⌘C, ⌘X and ⌘V take the objects selected together: a paste onto their own page lands 18 pt down
|
||||
/// and right, selected; a paste into another document draws them there; a cut is one step.
|
||||
#[test]
|
||||
fn objects_copy_cut_and_paste_with_the_keys_on_the_page_and_into_another_document() {
|
||||
let mut h = harness();
|
||||
h.state_mut().system_clipboard = false;
|
||||
let unchanged = can_undo(&h, 0);
|
||||
keys(&mut h, Modifiers::COMMAND, Key::A);
|
||||
keys(&mut h, Modifiers::COMMAND, Key::C);
|
||||
assert_eq!(can_undo(&h, 0), unchanged, "a copy changes nothing");
|
||||
assert!(h.state().object_clipboard.is_some());
|
||||
keys(&mut h, Modifiers::COMMAND, Key::V);
|
||||
assert_eq!(can_undo(&h, 0).as_deref(), Some("Paste"));
|
||||
let pasted = texts(&h, 0);
|
||||
for text in ["Group headline", "Keep here", "Added note"] {
|
||||
assert_eq!(pasted.iter().filter(|t| t.contains(text)).count(), 2, "{text}: {pasted:?}");
|
||||
}
|
||||
assert!(h.query_all_by_label_contains("objects selected").next().is_some(), "the pasted objects are selected");
|
||||
let moved = objects(&h).into_iter().filter(|o| o.text.as_deref() == Some("Keep here")).map(|o| o.rect).collect::<Vec<_>>();
|
||||
assert!((moved[1][0] - moved[0][0] - 18.0).abs() < 0.01 && (moved[0][1] - moved[1][1] - 18.0).abs() < 0.01, "{moved:?}");
|
||||
|
||||
// Into another open document, where it was: exactly over its own copy of the same page.
|
||||
h.state_mut().open_bytes("other.pdf", None, fixture(false)).unwrap();
|
||||
h.run_steps(4);
|
||||
let other = h.state().active.unwrap();
|
||||
assert_eq!(other, 1);
|
||||
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::EditText;
|
||||
h.run_steps(2);
|
||||
keys(&mut h, Modifiers::COMMAND, Key::V);
|
||||
assert_eq!(can_undo(&h, other).as_deref(), Some("Paste"));
|
||||
let there = texts(&h, other);
|
||||
assert!(["Group headline", "Keep here", "Added note"].iter().all(|t| there.iter().any(|x| x.contains(t))), "{there:?}");
|
||||
|
||||
// Cut: copied, then deleted in one step; a paste brings it back.
|
||||
keys(&mut h, Modifiers::COMMAND, Key::A);
|
||||
keys(&mut h, Modifiers::COMMAND, Key::X);
|
||||
assert!(texts(&h, other).is_empty(), "{:?}", texts(&h, other));
|
||||
assert_eq!(can_undo(&h, other).as_deref(), Some("Delete content"));
|
||||
keys(&mut h, Modifiers::COMMAND, Key::V);
|
||||
assert!(texts(&h, other).iter().any(|t| t.contains("Added note")));
|
||||
}
|
||||
|
||||
/// What another application (or another copy) put on the clipboard since wins ⌘V: the objects
|
||||
/// paste only while it still holds the text their copy put there.
|
||||
#[test]
|
||||
fn a_newer_copy_elsewhere_wins_over_the_copied_objects() {
|
||||
let mut h = harness();
|
||||
h.state_mut().system_clipboard = false;
|
||||
keys(&mut h, Modifiers::COMMAND, Key::A);
|
||||
keys(&mut h, Modifiers::COMMAND, Key::C);
|
||||
let copied = h.state().object_clipboard.as_ref().unwrap().clip().text();
|
||||
assert!(copied.contains("Group headline") && copied.contains("Added note"), "{copied}");
|
||||
let before = can_undo(&h, 0);
|
||||
h.event(egui::Event::Paste("a word copied in another application".into()));
|
||||
h.run_steps(4);
|
||||
assert_eq!(can_undo(&h, 0), before, "the newer text is not the objects");
|
||||
h.event(egui::Event::Paste(copied.replace('\n', "\r\n")));
|
||||
h.run_steps(4);
|
||||
assert_eq!(can_undo(&h, 0).as_deref(), Some("Paste"));
|
||||
}
|
||||
@@ -13,6 +13,30 @@ In **Edit a PDF → Edit text & images**, select several items on the same page:
|
||||
- Modifier-clicks prefer added items and text over background artwork, so labels remain selectable.
|
||||
- Clear the selection or double-click an item to return to ordinary text editing and image resizing.
|
||||
Finish or discard a text draft before starting a multi-selection.
|
||||
- **⌘A / Ctrl+A** selects every item on the current page (paragraphs, artwork and added items),
|
||||
as Acrobat's Edit PDF does, instead of the page's text. Inside a paragraph or text box open for
|
||||
typing it still selects that text.
|
||||
|
||||
## Copy, cut and paste
|
||||
|
||||
With items selected together, **⌘C / Ctrl+C** (Edit ▸ Copy) copies them and **⌘X / Ctrl+X**
|
||||
(Edit ▸ Cut) copies them, then deletes them as one undo step. **⌘V / Ctrl+V** (Edit ▸ Paste) pastes
|
||||
them onto the current page of any open document, also after the original changed or closed, as
|
||||
one undo step, and selects what it pasted. Onto the page they came from they land 18 points down
|
||||
and to the right, as Acrobat offsets a paste onto the same page; elsewhere where they were; each
|
||||
further paste onto a page steps another 18 points; a copy that would land off the page is centred.
|
||||
|
||||
A paste draws the same: it copies the items' content operators with the graphics state they were
|
||||
drawn in (transformation, clip, colours, transparency, text state) and every resource they use
|
||||
(fonts, images and Form artwork, ExtGStates, colour spaces, patterns, shadings), merged into the
|
||||
target page's resources under names that don't clash, as Ungroup merges them. Within a document the
|
||||
same font and image objects are drawn again. Into another document they are copied with what they
|
||||
refer to, each once whatever the cycles, sharing objects it already holds; a paste whose resources
|
||||
take more than 20,000 objects or 256 MB is refused with that reason. Pasted items are ordinary page
|
||||
content: an added text box comes as page text, untagged and in no layer. The system clipboard gets
|
||||
the copied text (or, without text, the first picture) for other applications; a newer copy there,
|
||||
here or in another application, wins ⌘V. Text drawn as a clipping path before the selection is
|
||||
refused, as is artwork without resources of its own that names one, where a paste would change it.
|
||||
|
||||
The supported items are the existing paragraph boxes, raster Image XObjects, whole Form artwork,
|
||||
and text/images added by PdfCraft. A Form remains one item, including its nested artwork and
|
||||
@@ -75,6 +99,17 @@ session. For a CLI script, include `doc_open` in that script before the move:
|
||||
]
|
||||
```
|
||||
|
||||
`{"all": true}` instead of `objects` takes every item on the page, as ⌘A does. `object_copy` takes
|
||||
the same references (with `cut: true` it deletes them afterwards, one undo step) and `object_paste
|
||||
{doc, page}` pastes them, into the same or another open document, as the app does:
|
||||
|
||||
```json
|
||||
[
|
||||
{"tool":"object_copy","args":{"doc":1,"page":1,"all":true}},
|
||||
{"tool":"object_paste","args":{"doc":2,"page":3}}
|
||||
]
|
||||
```
|
||||
|
||||
Use the actual generation and references from your document, rather than assuming these example
|
||||
values. `offset` is in displayed points: positive x moves right, positive y moves down. The optional
|
||||
generation guard rejects a changed document before interpreting references. Re-list after any edit,
|
||||
@@ -82,6 +117,9 @@ undo, redo, or save; there is no claim that an index persists across generations
|
||||
`pdfcraft-cli run --script steps.json --root DIR` or call these tools through the opt-in MCP server.
|
||||
|
||||
Regression coverage: `crates/edit/src/tests.rs` (glyph bytes, neighbours, split streams, shared Forms,
|
||||
rotated/cropped/UserUnit pages and refusal), `crates/engine/src/tests.rs` (permissions, history,
|
||||
rendering and save/reopen), `crates/automation/tests/it/automation.rs` (generation guards and hostile
|
||||
references), and `crates/ui-egui/tests/it/object_selection.rs` (real-shell selection and gestures).
|
||||
rotated/cropped/UserUnit pages and refusal), `crates/edit/src/objects/clip_tests.rs` (copies that
|
||||
render the same pixels, renamed resources, hostile content), `crates/engine/src/tests.rs` and
|
||||
`crates/engine/src/object_clip_tests.rs` (permissions, history, rendering, save/reopen, pastes into
|
||||
another document, cycles and the size limit), `crates/automation/tests/it/automation.rs` (generation
|
||||
guards, hostile references, copy/cut/paste tools), and `crates/ui-egui/tests/it/object_selection.rs`
|
||||
(real-shell selection, gestures, ⌘A and the clipboard keys).
|
||||
@@ -1561,9 +1561,9 @@ tier = "P1"
|
||||
milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/src/canvas/tests.rs::select_all_copies_the_whole_document_while_scrolling_continuously", "crates/ui-egui/src/canvas/tests.rs::select_all_of_the_document_only_while_scrolling_through_several_pages"]
|
||||
notes = "⌘A: the current page one page at a time; the whole document scrolling continuously (#2639), copied whole"
|
||||
tools = ["object_copy", "object_move"]
|
||||
evidence = ["crates/ui-egui/tests/it/viewer.rs::view_history_select_all_and_find_options", "crates/ui-egui/src/canvas/tests.rs::select_all_copies_the_whole_document_while_scrolling_continuously", "crates/ui-egui/src/canvas/tests.rs::select_all_of_the_document_only_while_scrolling_through_several_pages", "crates/ui-egui/tests/it/object_selection.rs::command_a_selects_every_object_on_the_page_to_move_and_delete_together", "crates/ui-egui/tests/it/object_selection.rs::command_a_in_an_open_paragraph_selects_its_text_not_the_objects", "crates/automation/tests/it/automation.rs::objects_copy_cut_and_paste_through_tools_within_and_across_documents"]
|
||||
notes = "⌘A: the current page one page at a time; the whole document scrolling continuously (#2639), copied whole. In Edit text & images (Acrobat's Edit PDF) ⌘A selects every object on the current page together instead (paragraphs, artwork, added content), to move with the arrows or a drag, delete or copy as one step; inside a paragraph open for typing it selects that text. Headless: `all: true` in object_move and object_copy. Not observed against Acrobat: whether its ⌘A there also reaches paths and inline images (not selectable here)."
|
||||
|
||||
[[feature]]
|
||||
id = "view.copy-with-layout"
|
||||
@@ -2117,9 +2117,9 @@ area = "C"
|
||||
tier = "P1"
|
||||
milestone = "M7"
|
||||
status = "partial"
|
||||
tools = ["content_copy"]
|
||||
evidence = ["crates/automation/tests/it/automation.rs::copying_added_content_through_tools", "crates/engine/src/tests.rs::pasted_added_content_keeps_its_style_and_picture", "crates/engine/src/tests.rs::an_added_jpeg_2000_image_is_copied_as_its_file", "crates/edit/src/tests.rs::a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page", "crates/edit/src/tests.rs::a_paste_steps_past_copies_of_itself_only"]
|
||||
notes = "Issue #2636, engine and automation part. Edit::PasteContent pastes an added text box or image copied in the same document or another, as one undo step, and `content_copy` drives it headlessly. A copy keeps a text box's text, font, size, colour, alignment and box, and an image's picture, box, turns, flips and crop. It lands where the item was, stepped 18 pt down and to the right past each copy already on the page, or at a given point, and stays on the page. Within a document a pasted image draws the same image object; into another document its picture goes as its JPEG or JPEG 2000 file, or else a PNG, so a colour profile is dropped and CMYK colours in a PNG are turned to RGB, and later pastes there reuse the first one's picture. The copy permission is honoured. Missing: the desktop app's ⌘X / ⌘C / ⌘V, Edit ▸ Cut and Paste and the right-click menu (follow-up PRs), the original document's paragraphs and images (open PRs #1625 and #2251), several items at once, and a check of Acrobat's own clipboard format (not observed)."
|
||||
tools = ["content_copy", "object_copy", "object_paste"]
|
||||
evidence = ["crates/ui-egui/tests/it/object_selection.rs::objects_copy_cut_and_paste_with_the_keys_on_the_page_and_into_another_document", "crates/ui-egui/tests/it/object_selection.rs::a_newer_copy_elsewhere_wins_over_the_copied_objects", "crates/automation/tests/it/automation.rs::objects_copy_cut_and_paste_through_tools_within_and_across_documents", "crates/engine/src/object_clip_tests.rs::objects_copied_from_one_document_paste_into_another_and_draw_the_same", "crates/engine/src/object_clip_tests.rs::pasting_into_another_document_follows_cycles_and_refuses_past_its_limit", "crates/edit/src/objects/clip_tests.rs::pasted_objects_draw_the_same_on_another_page_under_renamed_resources", "crates/edit/src/objects/clip_tests.rs::a_paste_onto_its_own_page_moves_by_the_offset_and_draws_the_same", "crates/automation/tests/it/automation.rs::copying_added_content_through_tools", "crates/engine/src/tests.rs::pasted_added_content_keeps_its_style_and_picture", "crates/engine/src/tests.rs::an_added_jpeg_2000_image_is_copied_as_its_file", "crates/edit/src/tests.rs::a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page", "crates/edit/src/tests.rs::a_paste_steps_past_copies_of_itself_only"]
|
||||
notes = "Existing page objects (#5989): in Edit text & images, ⌘C / ⌘X / ⌘V and Edit ▸ Copy, Cut and Paste act on the objects selected together (⌘A, a marquee, modifier-clicks, a paragraph's frame): paragraphs, Image and Form artwork and added content. Edit::PasteObjects pastes them onto the current page of any open document as one undo step, selected: onto their own page 18 pt down and right, elsewhere where they were, each further paste a step more, centred when off the page. The paste draws the same pixels (raster-compared in tests): their content operators with the graphics state they had, and every resource they use (fonts, XObjects, ExtGStates, colour spaces, patterns, shadings) merged into the page's under names that don't clash, as Ungroup merges them; into another document the objects they refer to are copied once each, through cycles, sharing what is there, refused past 20,000 objects or 256 MB. Cut deletes as one step. The system clipboard gets their text (or the first picture) for other applications, and a newer copy wins ⌘V. Headless: object_copy (all, cut) and object_paste. Limits: pasted content is untagged and in no layer, an added text box pastes as page text, text clipping what follows and artwork that inherits resources it names are refused, no right-click Cut/Copy/Paste for these objects yet, and Acrobat's own clipboard format was not observed. Issue #2636, engine and automation part. Edit::PasteContent pastes an added text box or image copied in the same document or another, as one undo step, and `content_copy` drives it headlessly. A copy keeps a text box's text, font, size, colour, alignment and box, and an image's picture, box, turns, flips and crop. It lands where the item was, stepped 18 pt down and to the right past each copy already on the page, or at a given point, and stays on the page. Within a document a pasted image draws the same image object; into another document its picture goes as its JPEG or JPEG 2000 file, or else a PNG, so a colour profile is dropped and CMYK colours in a PNG are turned to RGB, and later pastes there reuse the first one's picture. The copy permission is honoured. Missing: the desktop app's ⌘X / ⌘C / ⌘V, Edit ▸ Cut and Paste and the right-click menu (follow-up PRs), the original document's paragraphs and images (open PRs #1625 and #2251), several items at once, and a check of Acrobat's own clipboard format (not observed)."
|
||||
|
||||
[[feature]]
|
||||
id = "edit.image-replace"
|
||||
|
||||
Reference in new issue
Block a user