#![allow(clippy::expect_used, clippy::indexing_slicing)]
use pdfrum::{Color, Document, PageEdit, PathBuilder, Rect, SaveOptions, Update, VelloCpuBackend};
const HELLO: &str = "tests/fixtures/hello_world.pdf";
const SPLIT: &str = "tests/fixtures/split_streams.pdf";
const HELLO_SPLIT: &str = "tests/fixtures/hello_world_split_streams.pdf";
const RECTANGLES: &str = "tests/fixtures/rectangles.pdf";
const TWO_PAGES: &str = "tests/fixtures/hello_world_2_pages.pdf";
fn temp_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("pdfrum-mutation-{name}"));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).ok();
dir
}
fn round_trip(doc: &Document, edits: &[PageEdit], name: &str) -> Document {
let out = temp_dir(name).join("out.pdf");
doc.save_pages(&out, edits, &SaveOptions::default())
.expect("saves");
Document::open(&out).expect("reopens")
}
fn streams_of(page: &PageEdit) -> Vec<usize> {
page.objects()
.iter()
.map(pdfrum::PageObject::content_stream)
.map(|stream| stream.unwrap_or(usize::MAX))
.collect()
}
#[test]
fn an_unedited_page_is_saved_exactly_as_an_unedited_document_would_be() {
let doc = Document::open(HELLO).expect("open");
let page = doc.page(0).expect("page").edit();
assert!(!page.is_modified(), "opening a page is not editing it");
let mut edited = Vec::new();
doc.write_pages_to(&mut edited, &[page], &SaveOptions::default())
.expect("saves");
let mut plain = Vec::new();
doc.write_to(&mut plain, &SaveOptions::default())
.expect("saves");
let body = |bytes: &[u8]| -> Vec<u8> {
let end = bytes
.windows(b"trailer".len())
.position(|w| w == b"trailer")
.unwrap_or(bytes.len());
bytes.get(..end).unwrap_or(bytes).to_vec()
};
assert_eq!(
body(&edited),
body(&plain),
"an unedited page must not be rewritten"
);
let reopened = Document::open(&{
let out = temp_dir("unedited").join("out.pdf");
std::fs::write(&out, &edited).expect("writes");
out
})
.expect("reopens");
assert_eq!(reopened.page(0).expect("page").edit().len(), 2);
}
#[test]
fn reading_a_pages_objects_does_not_dirty_it() {
let doc = Document::open(RECTANGLES).expect("open");
let mut page = doc.page(0).expect("page").edit();
let count = page.objects().len();
assert_eq!(count, 8);
assert!(!page.is_modified());
assert!(page.object_mut(0).is_some());
assert!(page.is_modified());
}
#[test]
fn a_multi_stream_page_records_which_element_each_object_came_from() {
let doc = Document::open(SPLIT).expect("open");
let page = doc.page(0).expect("page").edit();
assert_eq!(page.len(), 19);
let streams = streams_of(&page);
assert_eq!(
streams,
[vec![0; 15], vec![1; 3], vec![2; 1]].concat(),
"got {streams:?}"
);
}
#[test]
fn emptying_one_content_stream_collapses_the_indices_after_it() {
let doc = Document::open(SPLIT).expect("open");
let mut page = doc.page(0).expect("page").edit();
for index in (0..page.len()).rev() {
if page
.objects()
.get(index)
.map(pdfrum::PageObject::content_stream)
== Some(Some(1))
{
assert!(page.remove(index).is_some());
}
}
assert_eq!(page.len(), 16);
let streams = streams_of(&page);
assert_eq!(
streams,
[vec![0; 15], vec![2; 1]].concat(),
"got {streams:?}"
);
let saved = round_trip(&doc, &[page], "collapse");
let reloaded = saved.page(0).expect("page").edit();
assert_eq!(reloaded.len(), 16);
let streams = streams_of(&reloaded);
assert_eq!(
streams,
[vec![0; 15], vec![1; 1]].concat(),
"got {streams:?}"
);
}
#[test]
fn removing_from_a_single_stream_page_leaves_the_survivor_at_index_zero() {
for remove in [0usize, 1] {
let doc = Document::open(HELLO).expect("open");
let mut page = doc.page(0).expect("page").edit();
assert_eq!(page.len(), 2);
assert!(page.remove(remove).is_some());
assert_eq!(page.len(), 1);
assert_eq!(streams_of(&page), vec![0]);
let saved = round_trip(&doc, &[page], &format!("single-{remove}"));
let reloaded = saved.page(0).expect("page").edit();
assert_eq!(reloaded.len(), 1);
assert_eq!(streams_of(&reloaded), vec![0]);
}
}
#[test]
fn emptying_a_single_stream_page_leaves_it_blank() {
let doc = Document::open(HELLO).expect("open");
let mut page = doc.page(0).expect("page").edit();
while !page.is_empty() {
assert!(page.remove(0).is_some());
}
assert_eq!(page.len(), 0);
let saved = round_trip(&doc, &[page], "blank");
assert_eq!(saved.page(0).expect("page").edit().len(), 0);
assert_eq!(saved.page_count(), 1);
assert!((saved.page(0).expect("page").width() - 200.0).abs() < 1e-9);
}
#[test]
fn emptying_every_element_of_an_array_leaves_the_page_blank() {
let doc = Document::open(HELLO_SPLIT).expect("open");
let mut page = doc.page(0).expect("page").edit();
assert_eq!(page.len(), 3);
while !page.is_empty() {
assert!(page.remove(0).is_some());
}
let saved = round_trip(&doc, &[page], "blank-array");
assert_eq!(saved.page(0).expect("page").edit().len(), 0);
}
#[test]
fn removing_one_of_two_objects_in_an_element_leaves_the_element_standing() {
let doc = Document::open(HELLO_SPLIT).expect("open");
let mut page = doc.page(0).expect("page").edit();
assert_eq!(streams_of(&page), vec![0, 0, 1]);
assert!(page.remove(0).is_some());
assert_eq!(page.len(), 2);
let saved = round_trip(&doc, &[page], "not-lonely");
let reloaded = saved.page(0).expect("page").edit();
assert_eq!(reloaded.len(), 2);
assert_eq!(streams_of(&reloaded), vec![0, 1]);
}
#[test]
fn removing_the_only_object_of_an_element_drops_the_element() {
let doc = Document::open(HELLO_SPLIT).expect("open");
let mut page = doc.page(0).expect("page").edit();
assert!(page.remove(2).is_some());
assert_eq!(page.len(), 2);
let saved = round_trip(&doc, &[page], "lonely");
let reloaded = saved.page(0).expect("page").edit();
assert_eq!(reloaded.len(), 2);
assert_eq!(streams_of(&reloaded), vec![0, 0]);
}
#[test]
fn inserting_places_objects_in_the_order_asked_for() {
let doc = Document::open(HELLO).expect("open");
let mut page = doc.page(0).expect("page").edit();
let red = || {
PathBuilder {
fill: Some(Color::from_rgb8(255, 0, 0)),
..PathBuilder::rect(Rect::new(10.0, 10.0, 30.0, 30.0))
}
.build()
};
assert!(page.insert(0, red()).is_ok());
assert_eq!(page.len(), 3);
assert!(matches!(page.objects()[0], pdfrum::PageObject::Path(_)));
let past = page.len() + 1;
let err = page.insert(past, red()).expect_err("index past the end");
assert_eq!(err.index, past);
assert_eq!(err.len, 3);
assert_eq!(page.len(), 3);
assert!(page.insert(3, red()).is_ok());
assert_eq!(page.len(), 4);
assert!(matches!(page.objects()[3], pdfrum::PageObject::Path(_)));
}
#[test]
fn an_inserted_objects_place_in_the_painting_order_survives_a_save() {
let doc = Document::open(HELLO).expect("open");
let mut page = doc.page(0).expect("page").edit();
let rect = |x: f64| {
PathBuilder {
fill: Some(Color::from_rgb8(0, 0, 255)),
..PathBuilder::rect(Rect::new(x, 10.0, x + 20.0, 30.0))
}
.build()
};
assert!(page.insert(0, rect(10.0)).is_ok());
assert!(page.insert(2, rect(50.0)).is_ok());
let kinds = |page: &PageEdit| -> Vec<&'static str> {
page.objects()
.iter()
.map(|o| match o {
pdfrum::PageObject::Path(_) => "path",
pdfrum::PageObject::Text(_) => "text",
_ => "other",
})
.collect()
};
assert_eq!(kinds(&page), vec!["path", "text", "path", "text"]);
let saved = round_trip(&doc, &[page], "z-order");
let reloaded = saved.page(0).expect("page").edit();
assert_eq!(kinds(&reloaded), vec!["path", "text", "path", "text"]);
}
#[test]
fn an_inserted_object_adopts_the_element_of_the_object_it_lands_before() {
let doc = Document::open(HELLO_SPLIT).expect("open");
let mut page = doc.page(0).expect("page").edit();
assert_eq!(streams_of(&page), vec![0, 0, 1]);
let rect = |color: Color| {
PathBuilder {
fill: Some(color),
..PathBuilder::rect(Rect::new(10.0, 10.0, 30.0, 30.0))
}
.build()
};
assert!(page.insert(0, rect(Color::from_rgb8(255, 0, 0))).is_ok());
assert!(page.insert(3, rect(Color::from_rgb8(0, 255, 0))).is_ok());
assert_eq!(page.len(), 5);
assert_eq!(streams_of(&page), vec![0, 0, 0, 1, 1]);
let saved = round_trip(&doc, &[page], "adopt");
let reloaded = saved.page(0).expect("page").edit();
assert_eq!(reloaded.len(), 5);
assert_eq!(streams_of(&reloaded), vec![0, 0, 0, 1, 1]);
}
#[test]
fn an_appended_object_becomes_a_new_content_element() {
let doc = Document::open(HELLO).expect("open");
let mut page = doc.page(0).expect("page").edit();
page.push(
PathBuilder {
fill: Some(Color::new([0.0, 0.5, 0.0, 1.0])),
..PathBuilder::rect(Rect::new(20.0, 20.0, 80.0, 60.0))
}
.build(),
);
let saved = round_trip(&doc, &[page], "append");
let reloaded = saved.page(0).expect("page").edit();
assert_eq!(reloaded.len(), 3);
assert_eq!(streams_of(&reloaded), vec![0, 0, 1]);
}
#[test]
fn a_hidden_object_stays_in_memory_and_vanishes_from_the_saved_file() {
let doc = Document::open(RECTANGLES).expect("open");
let mut page = doc.page(0).expect("page").edit();
assert_eq!(page.len(), 8);
assert!(page.hide(4).is_ok());
assert_eq!(page.is_visible(4), Some(false));
assert_eq!(page.len(), 8, "still eight in memory");
assert!(page.is_modified());
let saved = round_trip(&doc, &[page], "inactive");
assert_eq!(
saved.page(0).expect("page").edit().len(),
7,
"one fewer on disk"
);
}
#[test]
fn hiding_the_only_object_empties_the_page_and_showing_it_restores_it() {
let doc = Document::open(HELLO).expect("open");
let mut page = doc.page(0).expect("page").edit();
while page.len() > 1 {
assert!(page.remove(1).is_some());
}
let one_object = round_trip(&doc, &[page], "cycle-a");
assert_eq!(one_object.page(0).expect("page").edit().len(), 1);
let mut page = one_object.page(0).expect("page").edit();
assert!(page.hide(0).is_ok());
let hidden = round_trip(&one_object, &[page], "cycle-b");
assert_eq!(hidden.page(0).expect("page").edit().len(), 0);
let mut page = one_object.page(0).expect("page").edit();
assert!(page.hide(0).is_ok());
assert!(page.show(0).is_ok());
assert!(page.is_modified());
let shown = round_trip(&one_object, &[page], "cycle-c");
assert_eq!(shown.page(0).expect("page").edit().len(), 1);
}
#[test]
fn setting_visibility_to_its_current_value_does_not_dirty_the_page() {
let doc = Document::open(RECTANGLES).expect("open");
let mut page = doc.page(0).expect("page").edit();
assert!(page.show(0).is_ok());
assert!(!page.is_modified());
}
#[test]
fn transforming_an_object_dirties_it_and_the_move_survives_a_save() {
let doc = Document::open(RECTANGLES).expect("open");
let mut page = doc.page(0).expect("page").edit();
assert!(!page.is_modified());
let before = match &page.objects()[0] {
pdfrum::PageObject::Path(p) => p.object.path.clone(),
other => panic!("expected a path, got {other:?}"),
};
assert!(
page.transform(0, pdfrum::Affine::translate((25.0, 0.0)))
.is_ok()
);
assert!(page.is_modified());
let saved = round_trip(&doc, &[page], "transform");
let reloaded = saved.page(0).expect("page").edit();
assert_eq!(reloaded.len(), 8);
let after = match &reloaded.objects()[0] {
pdfrum::PageObject::Path(p) => {
p.object.matrix * p.object.path.clone()
}
other => panic!("expected a path, got {other:?}"),
};
let moved = kurbo::Shape::bounding_box(&after);
let original = kurbo::Shape::bounding_box(&before);
assert!(
(moved.x0 - original.x0 - 25.0).abs() < 0.5,
"expected a 25pt shift: {original:?} -> {moved:?}"
);
}
#[test]
fn show_hide_and_transform_refuse_an_index_that_is_not_on_the_page() {
let doc = Document::open(RECTANGLES).expect("open");
let mut page = doc.page(0).expect("page").edit();
let len = page.len();
assert_eq!(len, 8);
let missing = len;
let show = page.show(missing).expect_err("no such object");
assert_eq!(show.index, missing);
assert_eq!(show.len, len);
let hide = page.hide(missing).expect_err("no such object");
assert_eq!(hide.index, missing);
assert_eq!(hide.len, len);
let transform = page
.transform(missing, pdfrum::Affine::translate((1.0, 0.0)))
.expect_err("no such object");
assert_eq!(transform.index, missing);
assert_eq!(transform.len, len);
assert_eq!(page.len(), 8);
assert!(!page.is_modified());
}
#[test]
fn editing_one_of_two_pages_sharing_a_stream_leaves_the_other_alone() {
let doc = Document::open(TWO_PAGES).expect("open");
assert_eq!(doc.page_count(), 2);
let before = doc.page(1).expect("page").edit().len();
let mut page = doc.page(0).expect("page").edit();
assert!(page.remove(0).is_some());
let saved = round_trip(&doc, &[page], "shared");
assert_eq!(saved.page_count(), 2);
assert_eq!(
saved.page(1).expect("page").edit().len(),
before,
"the page that was not edited must be untouched"
);
assert_eq!(saved.page(0).expect("page").edit().len(), before - 1);
}
#[test]
fn two_pages_edited_in_one_save_both_take_effect() {
let doc = Document::open(TWO_PAGES).expect("open");
let mut first = doc.page(0).expect("page").edit();
let mut second = doc.page(1).expect("page").edit();
let before = second.len();
assert!(first.remove(0).is_some());
second.push(
PathBuilder {
fill: Some(Color::from_rgb8(255, 0, 0)),
..PathBuilder::rect(Rect::new(5.0, 5.0, 45.0, 25.0))
}
.build(),
);
let saved = round_trip(&doc, &[first, second], "both");
assert_eq!(saved.page(0).expect("page").edit().len(), before - 1);
assert_eq!(saved.page(1).expect("page").edit().len(), before + 1);
}
#[test]
fn a_saved_edit_can_be_reopened_and_edited_again() {
let doc = Document::open(RECTANGLES).expect("open");
let mut page = doc.page(0).expect("page").edit();
page.push(
PathBuilder {
fill: Some(Color::from_rgb8(0, 0, 255)),
..PathBuilder::rect(Rect::new(10.0, 10.0, 50.0, 30.0))
}
.build(),
);
let once = round_trip(&doc, &[page], "chain-a");
assert_eq!(once.page(0).expect("page").edit().len(), 9);
let mut page = once.page(0).expect("page").edit();
assert!(page.remove(0).is_some());
let twice = round_trip(&once, &[page], "chain-b");
assert_eq!(twice.page(0).expect("page").edit().len(), 8);
}
#[test]
fn an_incremental_save_of_an_edited_page_appends_and_still_reflects_the_edit() {
let doc = Document::open(RECTANGLES).expect("open");
let original = std::fs::read(RECTANGLES).expect("read");
let mut page = doc.page(0).expect("page").edit();
assert!(page.remove(0).is_some());
let out = temp_dir("incremental").join("out.pdf");
doc.save_pages(&out, &[page], &{
let mut __o = SaveOptions::default();
__o.update = Update::Incremental;
__o
})
.expect("saves");
let written = std::fs::read(&out).expect("read");
assert!(
written.starts_with(&original),
"an incremental save keeps the original bytes as its prefix"
);
let reopened = Document::open(&out).expect("reopens");
assert_eq!(reopened.page(0).expect("page").edit().len(), 7);
}
#[test]
fn an_edited_page_renders_and_renders_differently() {
use pdfrum::RenderOptions;
let doc = Document::open(RECTANGLES).expect("open");
let before = doc
.page(0)
.expect("page")
.render(&VelloCpuBackend::new(), &RenderOptions::default())
.expect("renders");
let mut page = doc.page(0).expect("page").edit();
while page.len() > 4 {
assert!(page.remove(page.len() - 1).is_some());
}
let saved = round_trip(&doc, &[page], "render");
let after = saved
.page(0)
.expect("page")
.render(&VelloCpuBackend::new(), &RenderOptions::default())
.expect("renders");
assert_eq!(
(before.width(), before.height()),
(after.width(), after.height())
);
let differing = (0..before.height())
.flat_map(|y| (0..before.width()).map(move |x| (x, y)))
.filter(|(x, y)| before.pixel(*x, *y) != after.pixel(*x, *y))
.count();
assert!(
differing > 0,
"removing half the objects must change pixels"
);
}
#[test]
fn a_saved_but_unedited_page_renders_identically() {
use pdfrum::RenderOptions;
let doc = Document::open(RECTANGLES).expect("open");
let before = doc
.page(0)
.expect("page")
.render(&VelloCpuBackend::new(), &RenderOptions::default())
.expect("renders");
let page = doc.page(0).expect("page").edit();
let saved = round_trip(&doc, &[page], "unedited-render");
let after = saved
.page(0)
.expect("page")
.render(&VelloCpuBackend::new(), &RenderOptions::default())
.expect("renders");
let differing = (0..before.height())
.flat_map(|y| (0..before.width()).map(move |x| (x, y)))
.filter(|(x, y)| before.pixel(*x, *y) != after.pixel(*x, *y))
.count();
assert_eq!(differing, 0, "an unedited page must render unchanged");
}