Files
pdfcraft/crates/engine/src/object_clip_tests.rs
T
BFlat e0f90d5f2d 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.
2026-11-09 02:32:50 +08:00

258 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)]);
}