`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
diff --git a/crates/automation/src/content.rs b/crates/automation/src/content.rs
index 1f26dcc26a..a77ef98b8b 100644
--- a/crates/automation/src/content.rs
+++ b/crates/automation/src/content.rs
@@ -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 {
+ /// 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> {
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 {
+ 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 {
+ 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 {
+ 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)
+ }
}
diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs
index a838241705..642c4b5b11 100644
--- a/crates/automation/src/dispatch.rs
+++ b/crates/automation/src/dispatch.rs
@@ -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)?,
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index 7e4443d244..6132ac7416 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -101,6 +101,8 @@ pub struct Automation {
/// Review sessions held for `ocr_words` (a guard and the pages read), by document.
ocr_reviews: HashMap,
next_ocr_review: u64,
+ /// What object_copy copied, for object_paste into any open document.
+ objects_clipboard: Option,
}
impl Default for Automation {
@@ -121,6 +123,7 @@ impl Automation {
evicted: HashSet::new(),
ocr_reviews: HashMap::new(),
next_ocr_review: 0,
+ objects_clipboard: None,
}
}
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index feface85e4..4cdd58f3c3 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -204,12 +204,22 @@ pub fn tools() -> Vec {
}), &["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")
diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs
index 4d0620addf..e1c596d74a 100644
--- a/crates/automation/tests/it/automation.rs
+++ b/crates/automation/tests/it/automation.rs
@@ -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");
diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs
index 8ac77c9deb..c4a39bf689 100644
--- a/crates/edit/src/images.rs
+++ b/crates/edit/src/images.rs
@@ -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, 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> {
diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs
index da51839047..39eeb1e5dc 100644
--- a/crates/edit/src/lib.rs
+++ b/crates/edit/src/lib.rs
@@ -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};
diff --git a/crates/edit/src/objects.rs b/crates/edit/src/objects.rs
index 6a64e3cfce..5b1a36caef 100644
--- a/crates/edit/src/objects.rs
+++ b/crates/edit/src/objects.rs
@@ -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;
diff --git a/crates/edit/src/objects/clip.rs b/crates/edit/src/objects/clip.rs
new file mode 100644
index 0000000000..4402667b76
--- /dev/null
+++ b/crates/edit/src/objects/clip.rs
@@ -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,
+ /// 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,
+}
+
+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::>().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 {
+ 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::>() {
+ 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::>();
+ 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::, _>>()?;
+ 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, shows: &HashMap) -> Result, 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;
diff --git a/crates/edit/src/objects/clip_tests.rs b/crates/edit/src/objects/clip_tests.rs
new file mode 100644
index 0000000000..980bb9ba4c
--- /dev/null
+++ b/crates/edit/src/objects/clip_tests.rs
@@ -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 {
+ 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::>());
+ 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();
+}
diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs
index be230d8db7..2e73a5a075 100644
--- a/crates/edit/src/text.rs
+++ b/crates/edit/src/text.rs
@@ -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)]
diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs
index 8741888c4e..4816271e30 100644
--- a/crates/edit/src/text/rewrite.rs
+++ b/crates/edit/src/text/rewrite.rs
@@ -1117,6 +1117,36 @@ fn vertical_encoding(doc: &Document, font: &Dict) -> Result {
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, 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, 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);
diff --git a/crates/edit/src/ungroup.rs b/crates/edit/src/ungroup.rs
index 1b1916a1c8..c8b0d2f4d4 100644
--- a/crates/edit/src/ungroup.rs
+++ b/crates/edit/src/ungroup.rs
@@ -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