From 6174ac97b99a67fab49765006018a9dd84841b12 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 9 Nov 2026 00:17:30 +0700 Subject: [PATCH] Edit a PDF: copy objects selected together and paste them as page content that draws the same (#5989) copy_objects replays the page's state operators up to the last selected object and paints only the selection; paste_objects draws the copy on top through Ungroup's resource merge, under names that don't clash. --- crates/edit/src/images.rs | 29 ++- crates/edit/src/lib.rs | 4 +- crates/edit/src/objects.rs | 3 + crates/edit/src/objects/clip.rs | 282 ++++++++++++++++++++++++++ crates/edit/src/objects/clip_tests.rs | 201 ++++++++++++++++++ crates/edit/src/text.rs | 2 +- crates/edit/src/text/rewrite.rs | 42 +++- crates/edit/src/ungroup.rs | 13 +- 8 files changed, 555 insertions(+), 21 deletions(-) create mode 100644 crates/edit/src/objects/clip.rs create mode 100644 crates/edit/src/objects/clip_tests.rs diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index e1b9e3626e..30739e16b5 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -1670,8 +1670,15 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag let res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); let translucent = crate::ungroup::translucent_at(doc, &res, &ops, img.op); let space = crate::ungroup::page_blend_space(doc, &p.dict); - let (inline, mut merged, patterns) = - crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent, space.as_ref(), Matrix(img.matrix))?; + let (inline, mut merged, patterns) = crate::ungroup::ungroup_ops( + doc, + &res, + img.name.as_bytes(), + translucent, + space.as_ref(), + Matrix(img.matrix), + "this artwork can't be ungrouped", + )?; if !patterns.is_empty() { let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); for (name, copy) in patterns { @@ -1883,6 +1890,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..44f7b0c7fe --- /dev/null +++ b/crates/edit/src/objects/clip.rs @@ -0,0 +1,282 @@ +//! 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, 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); + let (ops, mut merged, patterns) = ungroup_ops( + doc, + &with_copy, + &name, + false, + space.as_ref(), + Matrix::translate(offset[0], offset[1]), + "the copied objects can't be pasted here", + )?; + 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..ba41c634eb --- /dev/null +++ b/crates/edit/src/objects/clip_tests.rs @@ -0,0 +1,201 @@ +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); +} + +#[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 6ee805674e..b15aa4b0df 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -62,9 +62,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 76a1f3fe5c..45fffb294f 100644 --- a/crates/edit/src/ungroup.rs +++ b/crates/edit/src/ungroup.rs @@ -36,11 +36,11 @@ use crate::EditError; 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; @@ -57,7 +57,7 @@ pub(crate) type Ungrouped = (Vec, Dict, Vec<(Vec, Object)>); /// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a /// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]). /// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`, -/// then the CTM there). +/// then the CTM there). A refusal starts with `refusal` (what can't be done). pub(crate) fn ungroup_ops( doc: &Document, resources: &Dict, @@ -65,8 +65,9 @@ pub(crate) fn ungroup_ops( translucent: bool, page_space: Option<&Object>, placement: Matrix, + refusal: &str, ) -> Result { - let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}")); + let refuse = |why: &str| EditError::Invalid(format!("{refusal}: {why}")); let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?; let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?; let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) }; @@ -371,7 +372,7 @@ struct BlendWalk { } /// 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`, @@ -379,7 +380,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, Unreadable> { +pub(crate) fn resource_operand(op: &Op) -> Result, Unreadable> { let operands = &op.operands; let name_at = |i: Option| i.is_some_and(|i| matches!(operands.get(i), Some(Object::Name(_)))); let last = operands.len().checked_sub(1);