Files
pdfcraft/crates/edit/src/objects/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

229 lines
13 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 std::sync::Arc;
use pdfcraft_cos::{Document, SaveOptions, write_incremental};
use super::*;
/// A document of `objs` (object 1 the catalog), numbered in order.
fn pdf(objs: &[String]) -> Document {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
fn stream(content: &str) -> String {
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())
}
/// Two 300 × 300 pages. The first draws `source` with Helvetica as /F1, a 4 × 2 RGB image /Im0,
/// a half-opaque ExtGState /GS0 and Courier as /F2; the second draws `target` with Courier
/// under the same name /F1, so a paste from the first must rename the font it brings.
fn two_pages(source: &str, target: &str) -> Document {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 300] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R /F2 8 0 R >> /XObject << /Im0 9 0 R >> /ExtGState << /GS0 << /ca 0.5 /CA 0.5 >> >> >> >>"
.into(),
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 8 0 R >> >> >>".into(),
stream(source),
stream(target),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /XObject /Subtype /Image /Width 4 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 24 >>\nstream\n\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}\u{7f}@@@\u{0}\u{0}\u{0}\nendstream".into(),
])
}
/// What the page state, the copied paragraph and picture and the objects left out are drawn with.
const SOURCE: &str = "q 1 0 0 1 10 10 cm 0 0 270 270 re W n 0 0 1 rg /GS0 gs \
BT /F2 10 Tf 200 20 Td (Skip) Tj ET \
BT /F1 24 Tf 1.5 Tc 20 170 Td (Copied) Tj 0 -30 Td (Too) Tj ET \
q 1 0 0 rg 150 230 100 40 re f Q \
q 80 0 0 40 20 60 cm /Im0 Do Q Q \
BT /F1 12 Tf 200 250 Td (After) Tj ET";
fn target(doc: &Document, page: usize, want: impl Fn(&EditableObject) -> bool) -> ObjectTarget {
editable_objects(doc, page).unwrap().into_iter().find(|o| want(o)).unwrap().target
}
fn render(doc: &Document, page: usize) -> pdfcraft_render::RenderedPage {
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), pdfcraft_render::RenderConfig::default());
renderer.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() })
}
/// The pixels of `page` in user-space box `r` (a page 300 high at scale 1), shifted by `by`.
fn pixels(page: &pdfcraft_render::RenderedPage, r: [f64; 4], by: [f64; 2]) -> Vec<u8> {
let (x0, x1) = ((r[0] + by[0]) as u32, (r[2] + by[0]) as u32);
let (y0, y1) = ((300.0 - r[3] - by[1]) as u32, (300.0 - r[1] - by[1]) as u32);
let mut out = Vec::new();
for y in y0..y1 {
for x in x0..x1 {
let i = ((y * page.width + x) * 4) as usize;
out.extend_from_slice(&page.rgba[i..i + 4]);
}
}
out
}
/// The paragraph "Copied" / "Too" (one or two of them, as the lines group) and the picture.
fn copied_paragraph_and_picture(doc: &Document) -> CopiedObjects {
let targets: Vec<_> = editable_objects(doc, 0)
.unwrap()
.into_iter()
.filter(|o| o.target.kind == ObjectKind::Image || o.text.as_deref().is_some_and(|t| t.contains("Copied") || t.contains("Too")))
.map(|o| o.target)
.collect();
copy_objects(doc, 0, &targets).unwrap()
}
#[test]
fn a_copy_takes_the_selected_objects_their_state_and_only_the_resources_they_use() {
let doc = two_pages(SOURCE, "");
let copied = copied_paragraph_and_picture(&doc);
let content = String::from_utf8_lossy(&copied.content).into_owned();
assert!(content.contains("(Copied)") && content.contains("(Too)") && content.contains("/Im0 Do"), "{content}");
// The page's clip and its colour and transparency still apply; nothing else is painted.
assert!(content.contains("W\nn") && content.contains("/GS0 gs") && content.contains("rg"), "{content}");
assert!(!content.contains("Skip") && !content.contains("After") && !content.contains("\nf\n"), "{content}");
let names = |cat: &[u8]| copied.resources.get(cat).and_then(Object::as_dict).map(|d| d.iter().map(|(k, _)| k.clone()).collect::<Vec<_>>());
assert_eq!(names(b"Font"), Some(vec![b"F1".to_vec(), b"F2".to_vec()]), "the font of the skipped text stays selected for what follows");
assert_eq!(names(b"XObject"), Some(vec![b"Im0".to_vec()]));
assert_eq!(names(b"ExtGState"), Some(vec![b"GS0".to_vec()]));
assert!(copied.text().contains("Copied") && copied.text().contains("Too") && !copied.text().contains("Skip"), "{}", copied.text());
assert_eq!(pdfcraft_content::parse(&copied.content).ops.iter().filter(|o| o.is("q")).count(), 3, "balanced: {content}");
}
#[test]
fn pasted_objects_draw_the_same_on_another_page_under_renamed_resources() {
let mut doc = two_pages(SOURCE, "BT /F1 10 Tf 5 5 Td (x) Tj ET");
let copied = copied_paragraph_and_picture(&doc);
paste_objects(&mut doc, 1, &copied, [0.0, 0.0]).unwrap();
let (source, pasted) = (render(&doc, 0), render(&doc, 1));
let area = [copied.rect[0] - 4.0, copied.rect[1] - 4.0, copied.rect[2] + 4.0, copied.rect[3] + 4.0];
let (before, after) = (pixels(&source, area, [0.0, 0.0]), pixels(&pasted, area, [0.0, 0.0]));
assert!(before.chunks(4).any(|p| p != [255, 255, 255, 255]), "the copied objects are drawn");
assert_eq!(before, after, "the paste draws the same pixels");
let red = pixels(&pasted, [160.0, 240.0, 260.0, 280.0], [0.0, 0.0]);
assert!(red.chunks(4).all(|p| p == [255, 255, 255, 255]), "what wasn't selected isn't pasted");
// Its own /F1 (Courier) is left alone; the copy's Helvetica comes under another name.
let fonts =
pdfcraft_model::pages(&doc)[1].dict.get(b"Resources").map(|r| doc.resolve(r)).unwrap().as_dict().unwrap().get(b"Font").cloned().unwrap();
let fonts = doc.resolve(&fonts).as_dict().cloned().unwrap();
assert_eq!(fonts.get(b"F1"), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(8, 0))));
assert!(fonts.iter().any(|(k, v)| k != b"F1" && *v == Object::Ref(pdfcraft_cos::ObjRef::new(7, 0))), "{fonts:?}");
let texts: Vec<_> = editable_objects(&doc, 1).unwrap().into_iter().filter_map(|o| o.text).collect();
assert!(texts.iter().any(|t| t.contains("Copied")) && !texts.iter().any(|t| t.contains("Skip")), "{texts:?}");
assert_eq!(crate::images::page_images(&doc, 1).unwrap().len(), 1);
}
#[test]
fn a_paste_onto_its_own_page_moves_by_the_offset_and_draws_the_same() {
let mut doc = two_pages(SOURCE, "");
let copied = copied_paragraph_and_picture(&doc);
let before = render(&doc, 0);
paste_objects(&mut doc, 0, &copied, [150.0, 0.0]).unwrap();
let after = render(&doc, 0);
let area = [copied.rect[0] - 2.0, copied.rect[1] - 2.0, copied.rect[2] + 2.0, copied.rect[3] + 2.0];
assert!(area[2] + 150.0 < 300.0, "{area:?}");
// The copy (and the clip it is drawn in) lands 150 pt to the right, the original stays.
// Antialiasing elsewhere on the page may round an edge pixel one step differently.
let (moved, original) = (pixels(&after, area, [150.0, 0.0]), pixels(&before, area, [0.0, 0.0]));
assert_eq!(moved.len(), original.len());
assert!(original.iter().any(|v| *v != 255), "the copied objects are drawn");
assert!(moved.iter().zip(&original).all(|(a, b)| a.abs_diff(*b) <= 2), "the paste draws the same pixels, moved");
assert_eq!(pixels(&after, area, [0.0, 0.0]), original);
// Within the document and the page, the resources are the page's own, under their names.
let res = pdfcraft_model::pages(&doc)[0].dict.get(b"Resources").map(|r| doc.resolve(r)).unwrap();
let fonts = res.as_dict().unwrap().get(b"Font").and_then(Object::as_dict).cloned().unwrap();
assert_eq!(fonts.len(), 2, "{fonts:?}");
assert_eq!(editable_objects(&doc, 0).unwrap().iter().filter(|o| o.target.kind == ObjectKind::Image).count(), 2);
}
/// Artwork without resources of its own takes the names of the page drawing it. Pasted onto a
/// page whose names differ, artwork that names nothing still draws the same; artwork that names
/// a font would find another one, so the paste is refused.
#[test]
fn artwork_inheriting_resources_pastes_only_when_it_names_none() {
for (art, pastes) in [("0 0 1 rg 0 0 60 30 re f", true), ("BT /F1 9 Tf (n) Tj ET", false)] {
let mut doc = pdf(&[
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 300] >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> /XObject << /Art 8 0 R >> >> >>".into(),
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 9 0 R >> >> >>".into(),
stream("BT /F1 12 Tf 20 250 Td (Title) Tj ET q 1 0 0 1 20 100 cm /Art Do Q"),
stream(""),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 60 30] /Length {} >>\nstream\n{art}\nendstream", art.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>".into(),
]);
let all: Vec<_> = editable_objects(&doc, 0).unwrap().into_iter().map(|o| o.target).collect();
let copied = copy_objects(&doc, 0, &all).unwrap();
let pasted = paste_objects(&mut doc, 1, &copied, [0.0, 0.0]);
assert_eq!(pasted.is_ok(), pastes, "{art}: {pasted:?}");
if let Err(EditError::Invalid(why)) = pasted {
assert!(why.starts_with("the copied objects can't be pasted here") && why.contains("uses its resources"), "{why}");
}
}
}
#[test]
fn added_content_is_copied_with_everything_its_stream_paints() {
let mut doc = two_pages(SOURCE, "");
let item = crate::Content::Text(crate::AddedText {
rect: [20.0, 20.0, 200.0, 60.0],
text: "Added box".into(),
size: 12.0,
..crate::AddedText::default()
});
crate::add_content(&mut doc, 0, &item).unwrap();
let added = target(&doc, 0, |o| o.target.kind == ObjectKind::Added);
let copied = copy_objects(&doc, 0, &[added]).unwrap();
assert_eq!(copied.text(), "Added box");
paste_objects(&mut doc, 1, &copied, [0.0, 0.0]).unwrap();
let texts: Vec<_> = editable_objects(&doc, 1).unwrap().into_iter().filter_map(|o| o.text).collect();
assert_eq!(texts, ["Added box"], "pasted as the page's own text");
assert!(crate::list_added(&doc).iter().all(|a| a.page == 0));
}
#[test]
fn copying_and_pasting_refuse_what_they_cannot_do_and_change_nothing() {
let doc = two_pages(SOURCE, "");
let text = target(&doc, 0, |o| o.text.as_deref().is_some_and(|t| t.contains("Copied")));
assert!(copy_objects(&doc, 0, &[]).is_err());
assert!(copy_objects(&doc, 0, &[text, text]).is_err(), "selected twice");
assert!(copy_objects(&doc, 0, &[ObjectTarget { kind: ObjectKind::Image, index: 9 }]).is_err(), "stale");
assert!(copy_objects(&doc, 5, &[text]).is_err(), "no such page");
let copied = copy_objects(&doc, 0, &[text]).unwrap();
for offset in [[f64::NAN, 0.0], [f64::INFINITY, 0.0], [2e9, 0.0]] {
let mut work = doc.clone();
assert!(paste_objects(&mut work, 1, &copied, offset).is_err(), "{offset:?}");
assert!(!work.is_modified(), "a refused paste changes nothing");
}
assert!(paste_objects(&mut doc.clone(), 7, &copied, [0.0, 0.0]).is_err());
// Text drawn as a clipping path before the selection would clip it: refused, not dropped.
let clipped = two_pages("BT /F1 20 Tf 7 Tr 20 20 Td (Mask) Tj ET 0 Tr BT /F1 20 Tf 20 200 Td (Shown) Tj ET", "");
let shown = target(&clipped, 0, |o| o.text.as_deref() == Some("Shown"));
assert!(copy_objects(&clipped, 0, &[shown]).is_err());
// Stray `Q`, `ET` and an unfinished path or text object: the copy is still balanced.
let stray = two_pages("Q Q ET 10 10 m 20 20 l BT /F1 20 Tf 20 200 Td (Stray) Tj", "");
let shown = target(&stray, 0, |o| o.text.as_deref() == Some("Stray"));
let copied = copy_objects(&stray, 0, &[shown]).unwrap();
let ops = pdfcraft_content::parse(&copied.content).ops;
assert_eq!(ops.iter().filter(|o| o.is("q")).count(), ops.iter().filter(|o| o.is("Q")).count());
assert_eq!(ops.iter().filter(|o| o.is("BT")).count(), ops.iter().filter(|o| o.is("ET")).count());
let mut work = stray.clone();
paste_objects(&mut work, 1, &copied, [0.0, 0.0]).unwrap();
}