From d149d23320797f6b2a5eefff663a4ccf8995c5bd Mon Sep 17 00:00:00 2001 From: srt8hellcat2-ui Date: Fri, 23 Oct 2026 13:10:19 -0400 Subject: [PATCH 1/9] Rewrite an unsigned file in full when saving after deleting pages (#3407) --- crates/engine/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 68fc076b97..fefa778afa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -3945,6 +3945,11 @@ impl Session { // Signed documents are only ever saved incrementally: an edit that needs a full rewrite // (applying redactions, changing security, sanitizing) would invalidate the signatures. // So does any edit to a file rebuilt from object headers: it has no chain to append to. + // Deleted pages are freed, but an incremental save keeps them (and their text) in the + // earlier revision and the file does not shrink (#3407): rewrite an unsigned file in full. + if !signed && matches!(edit, Edit::DeletePages { .. }) { + next.require_full_save(); + } let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); if signed && ((rewrites(&next) && !rewrites(&editor.cos)) || (next.is_modified() && next.revisions().is_empty())) { return Err(EditError::SignedRewrite(edit.label().to_string())); From 143dc669f1605af02e17dbfbea01f627ebb71d6c Mon Sep 17 00:00:00 2001 From: srt8hellcat2-ui Date: Fri, 23 Oct 2026 13:11:56 -0400 Subject: [PATCH 2/9] Test saving after deleting pages (#3407) --- crates/engine/src/tests.rs | 44 +++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ed4e6efa1c..0338909317 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -505,6 +505,47 @@ fn edits_update_view_data_and_mark_dirty() { assert_eq!(page_texts(&s, id), ["Page 1", "Page 3"], "renderer refreshed"); } +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} + +#[test] +fn saving_after_deleting_pages_rewrites_the_file_without_them() { + let (mut s, id) = session_with(6); + let original = s.save_bytes(id).unwrap(); + s.apply(id, Edit::DeletePages { pages: vec![1, 2, 3, 4] }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.len() < original.len(), "the file shrank: {} -> {}", original.len(), saved.len()); + assert!(!saved.starts_with(original.as_slice()), "a full rewrite, not an update appended to the old file"); + assert!(!contains(&saved, b"Page 3"), "a deleted page's text is not left in an earlier revision"); + let mut again = Session::new(); + let id2 = again.open("saved.pdf", None, saved, None).unwrap(); + assert_eq!(page_texts(&again, id2), ["Page 1", "Page 6"]); +} + +#[test] +fn saving_after_deleting_pages_from_a_signed_file_stays_incremental() { + let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap(); + let digital_id = sign::pkcs12::open(&p12, "test").unwrap(); + let mut s = Session::new().with_clock(|| 1_800_000_000); + let id = s.open("fixture.pdf", None, Arc::new(fixture(3)), None).unwrap(); + let opts = SignOptions { page: 0, rect: Some([20.0, 20.0, 160.0, 50.0]), ..SignOptions::default() }; + let signed = s.sign(id, &digital_id, opts).unwrap(); + s.mark_signed(id, signed.clone(), None).unwrap(); + s.apply(id, Edit::DeletePages { pages: vec![2] }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.starts_with(signed.as_ref()), "the signed bytes stay an incremental update"); + assert!(s.get(id).unwrap().is_signed()); +} + +#[test] +fn saving_without_deleting_pages_stays_incremental() { + let (mut s, id) = session_with(3); + let original = s.save_bytes(id).unwrap(); + s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(saved.starts_with(original.as_slice()), "an edit that deletes nothing is still appended"); +} #[test] fn metadata_edit_keeps_the_renderer_without_changing_page_content() { let (mut s, id) = session_with(1); @@ -784,9 +825,10 @@ fn revert_preserves_the_saved_pages_after_undo() { #[test] fn full_save_is_readable_and_smaller_after_deletes() { let (mut s, id) = session_with(5); + let original = s.save_bytes(id).unwrap(); s.apply(id, Edit::DeletePages { pages: vec![0, 1, 2] }).unwrap(); let full = s.save_full_bytes(id).unwrap(); - assert!(full.len() < s.save_bytes(id).unwrap().len()); + assert!(full.len() < original.len()); let mut s2 = Session::new(); let id2 = s2.open("full.pdf", None, full, None).unwrap(); assert_eq!(page_texts(&s2, id2), ["Page 4", "Page 5"]); From 29d12c39e96105380f3234789c85b3e12b5c3f20 Mon Sep 17 00:00:00 2001 From: srt8hellcat2-ui Date: Fri, 23 Oct 2026 13:12:53 -0400 Subject: [PATCH 3/9] Add blank line between tests --- crates/engine/src/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0338909317..e94cdc1da9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -546,6 +546,7 @@ fn saving_without_deleting_pages_stays_incremental() { let saved = s.save_bytes(id).unwrap(); assert!(saved.starts_with(original.as_slice()), "an edit that deletes nothing is still appended"); } + #[test] fn metadata_edit_keeps_the_renderer_without_changing_page_content() { let (mut s, id) = session_with(1); From 17e2c35fdcb7d372a90c5a340c3414b32a6e8f24 Mon Sep 17 00:00:00 2001 From: srt8hellcat2-ui Date: Fri, 23 Oct 2026 13:14:11 -0400 Subject: [PATCH 4/9] Note the full-rewrite save on the Pages panel entry (#3407) --- parity/acrobat-features.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 6d950a71ca..01b694c97d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1275,7 +1275,7 @@ status = "shipped" commands = [] tools = [] evidence = ["crates/ui-egui/tests/it/editing.rs::dragging_pages_panel_thumbnails_reorders_pages", "crates/ui-egui/tests/it/viewer.rs::pages_panel_thumbnails_have_a_context_menu", "crates/ui-egui/tests/it/editing.rs::deleting_pages_from_the_pages_panel_menu", "crates/ui-egui/tests/it/editing.rs::the_delete_key_deletes_pages_picked_in_the_pages_panel_but_never_every_page"] -notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." +notes = "Drag the selection (or the page grabbed) to reorder, with a drop bar and edge scrolling; right-click offers Extract pages, Cut, Copy, Paste after and Delete pages (also the Delete key over the panel). Deleting pages makes the next Save a full rewrite (unsigned files), so the deleted pages leave the file; signed files stay incremental. Not yet: Rotate, Insert and Page labels in the menu, dragging pages between documents, or dropping files onto the panel." [[feature]] id = "view.bookmarks-panel" From cb17f8490dfa9b40c6040b1d9852167065f7895e Mon Sep 17 00:00:00 2001 From: srt8hellcat2-ui Date: Fri, 23 Oct 2026 13:30:10 -0400 Subject: [PATCH 5/9] Also rewrite in full when a batch edit deletes pages --- crates/engine/src/lib.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fefa778afa..55696608d8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -2888,6 +2888,15 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Ok(()) } +/// Whether the edit, or any edit inside a batch, deletes pages. +fn deletes_pages(edit: &Edit) -> bool { + match edit { + Edit::DeletePages { .. } => true, + Edit::Batch { edits, .. } => edits.iter().any(deletes_pages), + _ => false, + } +} + /// The comment list, read from the object graph (as `inspect` lists comments). fn comment_list(doc: &pdfcraft_cos::Document) -> Vec { pdfcraft_annot::summaries(doc) @@ -3947,7 +3956,7 @@ impl Session { // So does any edit to a file rebuilt from object headers: it has no chain to append to. // Deleted pages are freed, but an incremental save keeps them (and their text) in the // earlier revision and the file does not shrink (#3407): rewrite an unsigned file in full. - if !signed && matches!(edit, Edit::DeletePages { .. }) { + if !signed && deletes_pages(&edit) { next.require_full_save(); } let rewrites = |c: &pdfcraft_cos::Document| c.full_save_required() || c.encryption_changed(); From 12d89c551c9876cd24192e293dbb8f197e2b3199 Mon Sep 17 00:00:00 2001 From: srt8hellcat2-ui Date: Fri, 23 Oct 2026 13:31:10 -0400 Subject: [PATCH 6/9] Test that a batch deleting pages also saves in full --- crates/engine/src/tests.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e94cdc1da9..3ad72285f0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -538,6 +538,20 @@ fn saving_after_deleting_pages_from_a_signed_file_stays_incremental() { assert!(s.get(id).unwrap().is_signed()); } +#[test] +fn saving_after_a_batch_that_deletes_pages_rewrites_the_file() { + let (mut s, id) = session_with(6); + let original = s.save_bytes(id).unwrap(); + let batch = Edit::Batch { + label: "Delete and rotate".into(), + edits: vec![Edit::DeletePages { pages: vec![1, 2, 3, 4] }, Edit::RotatePages { pages: vec![0], degrees: 90 }], + }; + s.apply(id, batch).unwrap(); + let saved = s.save_bytes(id).unwrap(); + assert!(!saved.starts_with(original.as_slice()), "a full rewrite, not an update appended to the old file"); + assert!(!contains(&saved, b"Page 3"), "a deleted page's text is not left in an earlier revision"); +} + #[test] fn saving_without_deleting_pages_stays_incremental() { let (mut s, id) = session_with(3); From f3e8a9775a10a6dd99d347ddafa6267f6c69507c Mon Sep 17 00:00:00 2001 From: srt8hellcat2-ui Date: Fri, 23 Oct 2026 13:53:47 -0400 Subject: [PATCH 7/9] Nest the deleting batch in the regression test --- crates/engine/src/tests.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3ad72285f0..6b64952e13 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -544,7 +544,13 @@ fn saving_after_a_batch_that_deletes_pages_rewrites_the_file() { let original = s.save_bytes(id).unwrap(); let batch = Edit::Batch { label: "Delete and rotate".into(), - edits: vec![Edit::DeletePages { pages: vec![1, 2, 3, 4] }, Edit::RotatePages { pages: vec![0], degrees: 90 }], + edits: vec![ + Edit::Batch { + label: "Delete pages".into(), + edits: vec![Edit::DeletePages { pages: vec![1, 2, 3, 4] }], + }, + Edit::RotatePages { pages: vec![0], degrees: 90 }, + ], }; s.apply(id, batch).unwrap(); let saved = s.save_bytes(id).unwrap(); From 30438bf595904d7d39b22986dc364943d89b9040 Mon Sep 17 00:00:00 2001 From: srt8hellcat2-ui Date: Fri, 23 Oct 2026 16:23:04 -0400 Subject: [PATCH 8/9] Format the nested batch the way rustfmt wants --- crates/engine/src/tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6b64952e13..624079ca44 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -545,10 +545,7 @@ fn saving_after_a_batch_that_deletes_pages_rewrites_the_file() { let batch = Edit::Batch { label: "Delete and rotate".into(), edits: vec![ - Edit::Batch { - label: "Delete pages".into(), - edits: vec![Edit::DeletePages { pages: vec![1, 2, 3, 4] }], - }, + Edit::Batch { label: "Delete pages".into(), edits: vec![Edit::DeletePages { pages: vec![1, 2, 3, 4] }] }, Edit::RotatePages { pages: vec![0], degrees: 90 }, ], }; From 0475725247ec762247c3bb6a48d6e3abff9e4a06 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Sat, 31 Oct 2026 20:33:48 +0530 Subject: [PATCH 9/9] test(optimize): accept Flate or CCITT compression in monochrome scan test --- crates/optimize/tests/optimize.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index 2e3ea2d42f..683cbd0684 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -1775,7 +1775,8 @@ fn monochrome_images_keep_what_they_should() { optimize(&mut doc, &Settings::default()).unwrap(); assert_eq!([alone, deep].map(|r| raw(&doc, r)), before, "a Group 4 halftone alone, and sixteen-bit grey"); assert_eq!(filters(&doc, wrapped), 1, "a wrapped Group 4 halftone is stored again alone by default"); - assert_eq!(stream(&doc, scan).dict.name(b"Filter"), Some(&b"CCITTFaxDecode"[..]), "the scan: Group 4 is smaller"); + assert!(matches!(stream(&doc, scan).filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "the scan: Group 4 or Flate"); + assert_eq!(doc.decoded_image(&stream(&doc, scan)).unwrap(), ragged, "the scan preserves every sample"); assert_eq!((stream(&doc, fax).dict.name(b"Filter"), filters(&doc, fax)), (Some(&b"CCITTFaxDecode"[..]), 1), "the busy fax image"); let (mut doc, [_, wrapped, scan, _, _]) = build();