diff --git a/crates/automation/tests/it/portfolio_info.rs b/crates/automation/tests/it/portfolio_info.rs index 8da6310817..750a3e8840 100644 --- a/crates/automation/tests/it/portfolio_info.rs +++ b/crates/automation/tests/it/portfolio_info.rs @@ -4,6 +4,7 @@ use pdfcraft_automation::{Automation, Content}; use serde_json::{Value, json}; +/// Call tool `name` and return its JSON result. fn tool(a: &mut Automation, name: &str, args: Value) -> Value { match a.call(name, &args).unwrap().remove(0) { Content::Json(value) => value, @@ -34,6 +35,7 @@ trailer << /Root 1 0 R >> .into_bytes() } +/// `doc_info` names a portfolio's initial document and view, and reports none for a plain file. #[test] fn doc_info_names_the_document_a_portfolio_presents_first() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index 4602bc2e6b..fff53dca91 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -161,6 +161,8 @@ impl<'a> Inspector<'a> { me } + /// Read the document's catalog and pages into `info`: metadata, outline, fields, layers, + /// fonts, attachments, a portfolio and where it opens. pub(super) fn fill(&self, info: &mut DocInfo) { // lopdf drops /Encrypt from the trailer once it has decrypted the file. info.encrypted = self.doc.encrypted(); @@ -289,6 +291,7 @@ impl<'a> Inspector<'a> { Portfolio { initial, view } } + /// The object a reference points at, or `o` itself for a direct object or a broken reference. pub(super) fn resolve(&self, o: &'a Object) -> &'a Object { self.doc.dereference(o).map(|(_, o)| o).unwrap_or(o) } diff --git a/crates/render/src/inspect/portfolio_tests.rs b/crates/render/src/inspect/portfolio_tests.rs index 27c085cfae..301a9dab73 100644 --- a/crates/render/src/inspect/portfolio_tests.rs +++ b/crates/render/src/inspect/portfolio_tests.rs @@ -26,10 +26,13 @@ trailer << /Root 1 0 R >> .into_bytes() } +/// The portfolio [`portfolio`] with `collection` reads as. fn read(collection: Option<&str>) -> Option { inspect(Arc::new(portfolio(collection)), None).expect("opens").portfolio } +/// `/D` picks the attachment presented first, matched as the keys decode, and `/View` how the +/// list first shows. #[test] fn a_portfolio_presents_the_attachment_its_d_names() { // `/D` names `b.pdf` in the keys' own UTF-16, then as a literal string: both decode as the diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index d5550cdffc..90dbf7025c 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -247,6 +247,8 @@ impl PdfCraftApp { self.finish_open(name, path, bytes.len(), prepared, activate).map(|_| ()) } + /// Admit a prepared document: its tab, the panels and view it opens with, a portfolio's file + /// list and initial document, the recent-files entry and what its scripts said on opening. fn finish_open( &mut self, name: &str, @@ -316,9 +318,9 @@ impl PdfCraftApp { if opening_mode != Mode::Read && self.lists_files(id) { self.show_portfolio_files(id, right_before); } - if let Some(index) = portfolio_initial { - self.portfolio_initial = Some((id, index)); - } + // A document opening in front of a portfolio that hasn't presented its initial + // document yet (files opened together) keeps the front. + self.portfolio_initial = portfolio_initial.map(|index| (id, index)); } if activate && self.left_open @@ -428,7 +430,9 @@ impl PdfCraftApp { /// Leaving the tab gives back the panel the list replaced, unless the reader changed panels /// meanwhile. Called every frame. pub(crate) fn present_portfolios(&mut self) { - if let Some((doc, index)) = self.portfolio_initial.take() { + if let Some((doc, index)) = self.portfolio_initial.take() + && self.active_ids().is_some_and(|(_, id)| id == doc) + { self.attachment_action(doc, index, true); // A portfolio inside the portfolio waits until the reader opens it, so a nested // file can't open tab after tab on its own. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ac0f97f7fd..f5a1ed5440 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -269,7 +269,8 @@ pub struct PdfCraftApp { /// showing its comments or bookmarks (#1590). Remembered across restarts. pub right_panel_closed: bool, /// A portfolio that just opened and the attachment it presents first (`/Collection /D`), - /// opened by [`Self::present_portfolios`] on the next frame. + /// opened by [`Self::present_portfolios`] on the next frame while the portfolio is still the + /// active tab. portfolio_initial: Option<(DocId, usize)>, /// The document whose tab [`Self::present_portfolios`] last saw active. portfolio_seen: Option, diff --git a/crates/ui-egui/tests/it/portfolio.rs b/crates/ui-egui/tests/it/portfolio.rs index 615363bf26..d8dd2726b3 100644 --- a/crates/ui-egui/tests/it/portfolio.rs +++ b/crates/ui-egui/tests/it/portfolio.rs @@ -78,6 +78,8 @@ fn select(h: &mut Harness<'static, PdfCraftApp>, index: usize) { h.run_steps(2); } +/// The initial document opens in front, and the portfolio's tab lists its files whatever the +/// reader's panel preference, giving the panel back when left. #[test] fn a_portfolio_opens_its_initial_document_and_shows_its_files_with_the_panel_closed() { let mut h = opened(portfolio("/Collection << /D (form.pdf) /View /T >>", &[("intro.pdf", PAGE), ("form.pdf", PAGE)]), true); @@ -106,6 +108,7 @@ fn a_portfolio_opens_its_initial_document_and_shows_its_files_with_the_panel_clo assert_eq!(h.state().right, Some(RightPanel::Bookmarks)); } +/// The initial document opens on its own panels, not on the portfolio's file list. #[test] fn the_initial_document_opens_without_the_portfolios_file_list() { // The portfolio asks for the Attachments panel too (`/PageMode /UseAttachments`), and the @@ -118,6 +121,28 @@ fn the_initial_document_opens_without_the_portfolios_file_list() { assert_eq!(h.state().right, Some(RightPanel::Attachments)); } +/// A file opened together with a portfolio, after it, stays in front. +#[test] +fn a_file_opened_after_the_portfolio_keeps_the_front() { + // Two files opened at once, the portfolio first: the second stays in front, and the + // portfolio's initial document waits for the reader, whose list shows on the portfolio's tab. + let pdf = portfolio("/Collection << /D (form.pdf) >>", &[("form.pdf", PAGE)]); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.right_panel_closed = true; + app.open_bytes("portfolio.pdf", None, pdf.clone()).unwrap(); + app.open_bytes("other.pdf", None, PAGE.to_vec()).unwrap(); + app + }); + h.run_steps(4); + assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), "other.pdf".into()], Some("other.pdf".into()))); + assert_eq!(h.state().right, None); + select(&mut h, 0); + assert_eq!(h.state().right, Some(RightPanel::Attachments)); +} + +/// `/View /H` presents the initial document but leaves the file list to the reader. #[test] fn a_portfolio_that_keeps_its_list_hidden_still_presents_its_initial_document() { let mut h = opened(portfolio("/Collection << /D (form.pdf) /View /H >>", &[("intro.pdf", PAGE), ("form.pdf", PAGE)]), true); @@ -126,6 +151,7 @@ fn a_portfolio_that_keeps_its_list_hidden_still_presents_its_initial_document() assert_eq!(h.state().right, None, "/View /H: the list shows when the reader asks for it"); } +/// Without a PDF to present, the portfolio stays in front with its files listed. #[test] fn a_portfolio_without_an_initial_document_shows_its_files_over_the_cover() { // Without `/D` the cover is the initial document; a `/D` naming a file that isn't a PDF @@ -137,6 +163,7 @@ fn a_portfolio_without_an_initial_document_shows_its_files_over_the_cover() { } } +/// A portfolio nested in the initial document doesn't open a chain of tabs. #[test] fn a_portfolio_inside_the_initial_document_waits_for_the_reader() { // The initial document is itself a portfolio presenting a third file: opening the outer