From a204078cbbe87993c9460ab228f5a31b210414c1 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Thu, 22 Oct 2026 11:51:48 +0200 Subject: [PATCH] M4: Extract keeps the bookmarks of the pages it copies (#4020) page_extract (and Split, which extracts each part) wrote the copied pages without any outline, even when every page was extracted, though the tool says bookmarks come along. extract_pages now reads the source's bookmarks with the destinations mapped to the copies, as Combine already does, and writes them as the new document's outline. A bookmark whose page was left out goes, unless a bookmark under it stays. A source without bookmarks still gets no outline. Fixes #4020 --- crates/organize/src/import.rs | 28 +++++++++++++++++++++++++++- crates/organize/src/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/organize/src/import.rs b/crates/organize/src/import.rs index 80f90fb2a..4f3b1ffd3 100644 --- a/crates/organize/src/import.rs +++ b/crates/organize/src/import.rs @@ -844,12 +844,26 @@ fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<(), Organize /// A new document containing copies of `pages` from `src` (Extract Pages / Split). /// Document information (title, author…) is carried over, and so is the print standard the -/// source declares (PDF/X: its output intents and identification, #263). +/// source declares (PDF/X: its output intents and identification, #263), and the bookmarks that +/// go to the copied pages. pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { let mut out = Document::new_empty(); // One source: nothing to deduplicate. Its fields keep their names (a source with two // same-named field roots stays one field, as it was), so Extract and Split never rename. let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?; + // The bookmarks of these pages come along, mapped to the copies (#4020). + let outline = kept_bookmarks(source_outline(src, imported.page_map)); + if !outline.is_empty() { + let root = crate::outline::container(&mut out, &[])?; + if let Some((first, last, _)) = write_outline_level(&mut out, root, &outline)? { + out.update_dict(root, |d| { + d.set(b"First".to_vec(), Object::Ref(first)); + d.set(b"Last".to_vec(), Object::Ref(last)); + })?; + let top = crate::outline::children(&out, root); + crate::outline::relink_and_recount(&mut out, root, &top)?; + } + } for key in crate::INFO_KEYS { if let Some(v) = crate::info(src, key) { crate::set_info(&mut out, key, &v)?; @@ -1221,6 +1235,18 @@ fn source_outline(src: &Document, pages: HashMap) -> Vec) -> Vec { + nodes + .into_iter() + .filter_map(|mut n| { + n.children = kept_bookmarks(n.children); + (n.dest.is_some() || !n.children.is_empty()).then_some(n) + }) + .collect() +} + /// One level of bookmarks from `first` (its siblings), each with its children. Cycle- and size-safe: /// at most `budget` bookmarks are read in all, and nesting stops at 32 levels. fn outline_level(m: &Copier<'_>, first: Option, depth: u8, budget: &mut usize) -> Vec { diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 7a80e38e4..f79a4e95f 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -981,6 +981,28 @@ fn form_fields_come_along_and_are_registered() { assert_eq!(field.reference(b"P"), Some(pages(&out).unwrap()[0].obj)); } +/// #4020: Extract keeps the bookmarks that go to the pages it copies, mapped to the copies. One whose +/// page was left out goes, unless a bookmark under it stays. +#[test] +fn extract_keeps_the_bookmarks_of_the_pages_it_copies() { + let mut src = doc_a(); + crate::add_bookmark(&mut src, &[], 0, "One", 0).unwrap(); + crate::add_bookmark(&mut src, &[], 1, "Two", 1).unwrap(); + crate::add_bookmark(&mut src, &[1], 0, "Two.a", 2).unwrap(); + crate::add_bookmark(&mut src, &[], 2, "Three", 2).unwrap(); + let marks = |d: &Document| -> Vec<(String, usize, Option)> { bookmarks(d).into_iter().map(|b| (b.title, b.path.len(), b.page)).collect() }; + let s = |t: &str| t.to_string(); + + let all = full_roundtrip(&extract_pages(&src, &[0, 1, 2]).unwrap()); + assert_eq!(marks(&all), [(s("One"), 1, Some(0)), (s("Two"), 1, Some(1)), (s("Two.a"), 2, Some(2)), (s("Three"), 1, Some(2))]); + + let last = full_roundtrip(&extract_pages(&src, &[2]).unwrap()); + assert_eq!(marks(&last), [(s("Two"), 1, None), (s("Two.a"), 2, Some(0)), (s("Three"), 1, Some(0))], "One's page was left out"); + + let none = full_roundtrip(&extract_pages(&doc_a(), &[0]).unwrap()); + assert!(!none.get(none.root().unwrap()).as_dict().unwrap().contains(b"Outlines"), "no bookmarks, no outline"); +} + #[test] fn popup_cycles_copy_once_and_structure_links_are_removed() { let out = full_roundtrip(&extract_pages(&doc_a(), &[2]).unwrap());