Merge #5259: M12: a PDF portfolio opens on its initial document and shows its files (#1652)

# Conflicts:
#	crates/render/src/inspect.rs
#	crates/render/src/lib.rs
This commit is contained in:
bflatastic
2026-11-09 18:24:28 +07:00
16 changed files with 631 additions and 11 deletions
+5
View File
@@ -342,6 +342,11 @@ pub(crate) fn info(d: &Document) -> Value {
"display": t.display,
})).collect::<Vec<_>>(),
"attachments": i.attachments.iter().map(|a| json!({ "name": a.name, "description": a.description, "size": a.size })).collect::<Vec<_>>(),
// A PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): its pages are a cover sheet and its
// documents are `attachments`; `initial` names the one presented first, null for the cover.
"portfolio": i.portfolio.as_ref().map(|p| json!({
"initial": p.initial.and_then(|n| i.attachments.get(n)).map(|a| &a.name), "view": p.view.name(),
})),
// `substitute` names the face standing in for a program the document does not embed, and
// `substitution` says why. Both are null for an embedded font: nothing stands in. A null
// `substitute` with a non-null `substitution` means no face was available at all, so
+1 -1
View File
@@ -185,7 +185,7 @@ pub fn tools() -> Vec<ToolDef> {
.cmd("file.open")
.with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])),
t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])),
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security, repair notes and the objects that could not be read.")
t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments (and, for a PDF portfolio, the one it presents first), fonts, security, repair notes and the objects that could not be read.")
.ro()
.cmd("file.properties")
.with(schema(json!({ "doc": doc() }), &["doc"])),
+1
View File
@@ -14,6 +14,7 @@ mod hostile_args;
mod model_3d;
mod model_render;
mod optimize_content_boundaries;
mod portfolio_info;
mod redact_after_stray_q_form;
mod redact_text_after_q;
mod render_warnings;
@@ -0,0 +1,54 @@
//! `doc_info` reports a PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6): the attachment it
//! presents first and how its file list first shows.
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,
other => panic!("expected JSON: {other:?}"),
}
}
/// A one-page portfolio of `cover.txt` and `form.pdf` with `collection` as its `/Collection`
/// entries, or none at all.
fn portfolio(collection: Option<&str>) -> Vec<u8> {
let collection = collection.map(|c| format!("/Collection << /Type /Collection {c} >>")).unwrap_or_default();
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R {collection} /Names << /EmbeddedFiles << /Names [(cover.txt) 5 0 R (form.pdf) 7 0 R] >> >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj
4 0 obj << /Type /EmbeddedFile /Length 5 >> stream
hello
endstream endobj
5 0 obj << /Type /Filespec /F (cover.txt) /UF (cover.txt) /EF << /F 4 0 R >> >> endobj
6 0 obj << /Type /EmbeddedFile /Length 8 >> stream
%PDF-1.7
endstream endobj
7 0 obj << /Type /Filespec /F (form.pdf) /UF (form.pdf) /EF << /F 6 0 R >> >> endobj
trailer << /Root 1 0 R >>
%%EOF"
)
.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();
std::fs::write(dir.path().join("tile.pdf"), portfolio(Some("/D (form.pdf) /View /T"))).unwrap();
std::fs::write(dir.path().join("cover.pdf"), portfolio(Some(""))).unwrap();
std::fs::write(dir.path().join("plain.pdf"), portfolio(None)).unwrap();
let mut auto = Automation::new().with_root(dir.path()).unwrap();
let mut info = |path: &str| {
let doc = tool(&mut auto, "doc_open", json!({ "path": path }))["doc"].as_u64().unwrap();
tool(&mut auto, "doc_info", json!({ "doc": doc }))["portfolio"].clone()
};
assert_eq!(info("tile.pdf"), json!({ "initial": "form.pdf", "view": "tile" }));
// Without `/D` the cover sheet is the initial document.
assert_eq!(info("cover.pdf"), json!({ "initial": null, "view": "details" }));
assert_eq!(info("plain.pdf"), Value::Null);
}
+5 -1
View File
@@ -37,7 +37,7 @@ pub use core::{check_pages, inspect, pretty_date};
use core::{inspect_structure, spend};
pub use model::{
Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo,
PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, link_replies, thread_tops,
PageTransition, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa, link_replies, thread_tops,
};
#[cfg(test)]
use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW};
@@ -76,6 +76,8 @@ pub struct DocInfo {
pub layer_groups: Vec<Vec<(u32, u16)>>,
pub fonts: Vec<FontInfo>,
pub attachments: Vec<Attachment>,
/// The document is a PDF portfolio (`/Collection`): its documents are `attachments`.
pub portfolio: Option<Portfolio>,
/// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order.
pub transitions: Vec<PageTransition>,
/// Where the document opens (`/OpenAction`): the (0-based page, view) of its destination,
@@ -85,5 +87,7 @@ pub struct DocInfo {
pub warnings: Vec<String>,
}
#[cfg(test)]
mod portfolio_tests;
#[cfg(test)]
mod tests;
+24 -3
View File
@@ -1,5 +1,5 @@
use super::annotations::{FieldWalk, MAX_LINK_ATTACHMENT_TEXT_BYTES};
use super::model::{AttachmentSource, PageInfo, Xfa};
use super::model::{Attachment, AttachmentSource, PageInfo, Portfolio, PortfolioView, Xfa};
use super::navigation::text_string;
use super::{
Arc, AssertUnwindSafe, DecryptionError, Dictionary, DocInfo, Document, HashMap, HashSet, LazyStructure, LoadOptions, LoadPdfError, Object,
@@ -177,8 +177,8 @@ impl<'a> Inspector<'a> {
}
/// Reads what `info` holds beyond the page list: encryption, document information, tagging,
/// page labels, bookmarks, annotations, transitions, form fields, layers, fonts, attachments,
/// JavaScript and the opening destination.
/// page labels, bookmarks, annotations, transitions, form fields, layers, fonts, attachments
/// (and, for a PDF portfolio, how it presents them), JavaScript and the opening destination.
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();
@@ -279,6 +279,7 @@ impl<'a> Inspector<'a> {
}
info.has_javascript |= names.get(b"JavaScript").is_ok();
}
info.portfolio = catalog.get(b"Collection").ok().and_then(|o| self.dict(o)).map(|c| self.portfolio(c, &info.attachments));
if let Ok(action) = catalog.get(b"OpenAction")
&& let Some(dict) = self.dict(action)
&& dict.get(b"S").ok().and_then(|o| self.resolve(o).as_name().ok()) == Some(&b"JavaScript"[..])
@@ -288,6 +289,26 @@ impl<'a> Inspector<'a> {
info.open_dest = catalog.get(b"OpenAction").ok().and_then(|o| self.open_dest(o));
}
/// A portfolio's collection dictionary (Table 153). `/D` names the initial document's key in
/// the `/EmbeddedFiles` name tree, decoded as the keys are.
fn portfolio(&self, collection: &Dictionary, attachments: &[Attachment]) -> Portfolio {
let view = match self.name(collection, b"View").as_deref() {
Some("T") => PortfolioView::Tile,
Some("H") => PortfolioView::Hidden,
Some("C") => PortfolioView::Custom,
_ => PortfolioView::Details,
};
let initial = match collection.get(b"D").map(|o| self.resolve(o)) {
Ok(Object::String(bytes, _)) => {
let name = text_string(bytes);
attachments.iter().position(|a| matches!(&a.source, AttachmentSource::Document { key } if *key == name))
}
_ => None,
};
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)
}
+38
View File
@@ -669,6 +669,44 @@ pub enum AttachmentSource {
Annotation { page: usize, index: usize },
}
/// A PDF portfolio: a catalog with a `/Collection` (ISO 32000-2 §7.11.6, Table 153). Its pages
/// are a cover sheet for viewers that show no file list; its documents are its attachments.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Portfolio {
/// The document to present first (`/D`): the index in `DocInfo::attachments` of the
/// `/EmbeddedFiles` entry it names. `None` without `/D` (the portfolio's own pages come
/// first) or when it names no listed attachment.
pub initial: Option<usize>,
/// How the file list first shows (`/View`).
pub view: PortfolioView,
}
/// A portfolio's initial view (`/Collection /View`, Table 153).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PortfolioView {
/// `D`, the default: the files with their details.
#[default]
Details,
/// `T`: the files as tiles.
Tile,
/// `H`: the file list starts hidden, shown when the reader asks for it.
Hidden,
/// `C`: a custom navigator (`/Navigator`).
Custom,
}
impl PortfolioView {
/// The view's name in `doc_info`.
pub fn name(self) -> &'static str {
match self {
Self::Details => "details",
Self::Tile => "tile",
Self::Hidden => "hidden",
Self::Custom => "custom",
}
}
}
/// A font used by the document (Document Properties ▸ Fonts).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FontInfo {
@@ -0,0 +1,53 @@
//! PDF portfolios (`/Collection`, ISO 32000-2 §7.11.6): the attachment a portfolio presents
//! first, and how its file list first shows.
use super::{Arc, Portfolio, PortfolioView, inspect};
/// A one-page portfolio of `a.txt` and `b.pdf`, keyed in UTF-16 as portfolio writers key them,
/// with `collection` as its `/Collection` entries (`None`: not a portfolio).
fn portfolio(collection: Option<&str>) -> Vec<u8> {
let collection = collection.map(|c| format!("/Collection << /Type /Collection {c} >>")).unwrap_or_default();
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R {collection} /Names << /EmbeddedFiles << /Names [<FEFF0061002E007400780074> 5 0 R <FEFF0062002E007000640066> 7 0 R] >> >> >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj
4 0 obj << /Type /EmbeddedFile /Length 5 >> stream
hello
endstream endobj
5 0 obj << /Type /Filespec /F (a.txt) /UF (a.txt) /EF << /F 4 0 R >> >> endobj
6 0 obj << /Type /EmbeddedFile /Length 8 >> stream
%PDF-1.7
endstream endobj
7 0 obj << /Type /Filespec /F (b.pdf) /UF (b.pdf) /EF << /F 6 0 R >> >> endobj
trailer << /Root 1 0 R >>
%%EOF"
)
.into_bytes()
}
/// The portfolio [`portfolio`] with `collection` reads as.
fn read(collection: Option<&str>) -> Option<Portfolio> {
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
// keys do.
let tile = Some(Portfolio { initial: Some(1), view: PortfolioView::Tile });
assert_eq!(read(Some("/D <FEFF0062002E007000640066> /View /T")), tile);
assert_eq!(read(Some("/D (b.pdf) /View /T")), tile);
// Without `/D` the portfolio's own pages come first, and a `/D` naming no file presents none.
assert_eq!(read(Some("")), Some(Portfolio { initial: None, view: PortfolioView::Details }));
assert_eq!(read(Some("/D (c.pdf) /View /D")), Some(Portfolio { initial: None, view: PortfolioView::Details }));
// `/View /H` keeps the file list hidden; `/C` is a custom navigator; an unknown view is
// the default, details.
assert_eq!(read(Some("/D (a.txt) /View /H")), Some(Portfolio { initial: Some(0), view: PortfolioView::Hidden }));
assert_eq!(read(Some("/View /C")).map(|p| p.view), Some(PortfolioView::Custom));
assert_eq!(read(Some("/View /Bogus /D 7")), Some(Portfolio { initial: None, view: PortfolioView::Details }));
// The same attachments without a `/Collection` are no portfolio.
assert_eq!(read(None), None);
}
+2 -2
View File
@@ -19,8 +19,8 @@ pub mod transition;
pub use inspect::{
Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo,
PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date,
thread_tops,
PageTransition, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa, attachment_data, check_pages, inspect,
link_replies, pretty_date, thread_tops,
};
pub use pixels::Pixels;
pub use raster::{
+6
View File
@@ -88,6 +88,11 @@ impl PdfCraftApp {
right_before_read: None,
comments_panel_closed: false,
right_panel_closed: false,
portfolio_initial: None,
portfolio_seen: None,
#[cfg(target_arch = "wasm32")]
web_reads: Default::default(),
portfolio_panel: None,
quick_tool: QuickTool::Select,
space_hand: None,
full_screen_tool: None,
@@ -637,6 +642,7 @@ impl PdfCraftApp {
OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
self.present_portfolios();
if let Some((_, id)) = self.active_ids() {
let out = self.session.take_js_output(id);
self.handle_js(id, out);
+123
View File
@@ -314,6 +314,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,
@@ -324,6 +326,12 @@ impl PdfCraftApp {
) -> Result<DocId, String> {
let id = self.session.commit_open(prepared);
self.password_prompt = None;
// A tab opening in front leaves a portfolio's tab, whose file list gives the panel back:
// the document a portfolio presents opens with the panels any other would.
if activate {
self.leave_portfolio();
}
let right_before = self.right;
// The reading-colours preference is app-wide: a newly opened document starts with it.
// Idempotent: the engine returns `false` when the document has the setting already.
self.session.set_reading_colors(id, self.reading_colors);
@@ -334,6 +342,13 @@ impl PdfCraftApp {
// the user closed stays closed (#1590).
let opening_mode = self.mode_override.unwrap_or(self.default_mode);
let initial = doc.initial_view();
// A portfolio presents the document its `/D` names (ISO 32000-2 Table 153), kept by its
// name-tree key, which an edit meanwhile can't shift as it can an index.
let portfolio_initial =
doc.info.portfolio.as_ref().and_then(|p| p.initial).and_then(|i| doc.info.attachments.get(i)).and_then(|a| match &a.source {
pdfcraft_render::AttachmentSource::Document { key } => Some(key.clone()),
pdfcraft_render::AttachmentSource::Annotation { .. } => None,
});
if activate && self.right.is_none() && !self.right_panel_closed && opening_mode != Mode::Read {
self.right = if !doc.info.annotations.is_empty() {
Some(RightPanel::Comments)
@@ -366,6 +381,16 @@ impl PdfCraftApp {
if activate && initial.full_screen {
self.pending_full_screen = Some(id);
}
if activate {
// A portfolio's tab shows its files from the start, in place of the panel there was
// before it opened, whichever panel its page mode asked for.
if opening_mode != Mode::Read && self.lists_files(id) {
self.show_portfolio_files(id, right_before);
}
// 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(|key| (id, key));
}
if activate
&& self.left_open
&& self.left == LeftPanel::Tool("organize")
@@ -475,6 +500,102 @@ impl PdfCraftApp {
}
}
/// A PDF portfolio (ISO 32000-2 §7.11.6) shows its documents, not the cover sheet that its
/// pages are. The document a portfolio that just opened presents first (`/D`) opens in a
/// tab of its own, and a portfolio's tab shows its file list, the Attachments panel, even
/// with the right panel closed, unless the portfolio keeps the list hidden (`/View /H`).
/// 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) {
// A prompt or picker over the window belongs to the document it was raised for (the
// password of a file opened together with the portfolio, say): the initial document
// waits for its answer, and opening it then would close the prompt. A file still
// opening in the background (in the open worker, or a browser read of a drop or a
// picked file) comes to the front when it arrives, so it is waited for too.
#[cfg(not(target_arch = "wasm32"))]
let busy = self.modal_open() || self.pickers.pending() || self.pending_opens.iter().any(|p| p.activate);
#[cfg(target_arch = "wasm32")]
let busy = self.modal_open() || self.web_reads.load(std::sync::atomic::Ordering::Relaxed) > 0;
if !busy
&& let Some((doc, key)) = self.portfolio_initial.take()
&& self.active_ids().is_some_and(|(_, id)| id == doc)
&& let Some(index) = self.portfolio_attachment(doc, &key)
{
self.open_portfolio_initial(doc, index);
// A portfolio inside the portfolio waits until the reader opens it, so a nested
// file can't open tab after tab on its own.
self.portfolio_initial = None;
}
let active = self.active_ids().map(|(_, id)| id);
if active == self.portfolio_seen {
return;
}
self.leave_portfolio();
if let Some(id) = active
&& self.mode != Mode::Read
&& self.right != Some(RightPanel::Attachments)
&& self.lists_files(id)
{
self.show_portfolio_files(id, self.right);
}
self.portfolio_seen = active;
}
/// The document is a portfolio whose tab shows its file list: one that doesn't keep the
/// list hidden (`/View /H`).
fn lists_files(&self, id: DocId) -> bool {
self.session.get(id).and_then(|d| d.info.portfolio.as_ref()).is_some_and(|p| p.view != pdfcraft_render::PortfolioView::Hidden)
}
/// Show portfolio `id`'s file list in place of `before`, the panel given back when its tab
/// is left.
fn show_portfolio_files(&mut self, id: DocId, before: Option<RightPanel>) {
self.portfolio_seen = Some(id);
self.portfolio_panel = Some(before);
self.right = Some(RightPanel::Attachments);
}
/// Where in `doc`'s attachments now the file its name tree keys `key` is: an attachment
/// deleted since the portfolio opened moves it, or it is gone.
fn portfolio_attachment(&self, doc: DocId, key: &str) -> Option<usize> {
self.session
.get(doc)?
.info
.attachments
.iter()
.position(|a| matches!(&a.source, pdfcraft_render::AttachmentSource::Document { key: k } if k == key))
}
/// Open portfolio `doc`'s attachment `index` in a tab of its own when its bytes are a PDF,
/// whatever its name. A file of another kind, or one that can't be read, stays in the list.
fn open_portfolio_initial(&mut self, doc: DocId, index: usize) {
let Some(d) = self.session.get(doc) else { return };
let Some(att) = d.info.attachments.get(index).cloned() else { return };
let Ok(bytes) = pdfcraft_render::attachment_data(&d.bytes, d.password.as_deref(), &att) else { return };
if pdfcraft_engine::source_kind(&att.name, &bytes) != Some(pdfcraft_engine::SourceKind::Pdf) {
return;
}
if let Err(e) = self.open_bytes(&crate::files::attachment_file_name(&att.name), None, bytes) {
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &att.name), ("e", &e.to_string())]);
}
}
/// Leave a portfolio's tab: the panel its file list replaced comes back, unless the reader
/// changed panels meanwhile. In Read mode, which put the list away and gives it back on
/// leaving, it is the panel Read mode gives back that changes, and a panel the reader picked
/// in Read mode stays.
fn leave_portfolio(&mut self) {
self.portfolio_seen = None;
let Some(before) = self.portfolio_panel.take() else { return };
if self.mode == Mode::Read {
if self.right_before_read == Some(RightPanel::Attachments) {
self.right_before_read = before;
}
} else if self.right == Some(RightPanel::Attachments) {
self.right = before;
}
}
/// [`Self::attachment_action`] for the file of the attachment comment at (page, index).
pub fn comment_attachment_action(&mut self, doc: DocId, page: usize, index: usize, open: bool) {
let source = pdfcraft_render::AttachmentSource::Annotation { page, index };
@@ -817,7 +938,9 @@ impl PdfCraftApp {
{
let inbox = self.inbox.clone();
let ctx = self.ctx.clone();
let read = super::input::WebRead::start(&self.web_reads);
wasm_bindgen_futures::spawn_local(async move {
let _read = read;
if let Some(h) = rfd::AsyncFileDialog::new()
.add_filter_any_case("PDF", &["pdf"])
.add_filter_any_case(tl!("Images and text"), &create_ui::CONVERTIBLE)
+24
View File
@@ -129,7 +129,9 @@ impl PdfCraftApp {
self.startup_superseded = true;
let inbox = self.inbox.clone();
let ctx = ctx.clone();
let read = WebRead::start(&self.web_reads);
wasm_bindgen_futures::spawn_local(async move {
let _read = read;
let name = crate::files::dropped_name(&f);
if let Ok(bytes) = f.bytes_async().await
&& let Ok(mut q) = inbox.lock()
@@ -140,3 +142,25 @@ impl PdfCraftApp {
});
}
}
/// One browser file read counted in `PdfCraftApp::web_reads` for as long as it lives, so the
/// count falls however the read ends.
#[cfg(target_arch = "wasm32")]
pub(super) struct WebRead(std::sync::Arc<std::sync::atomic::AtomicUsize>);
#[cfg(target_arch = "wasm32")]
impl WebRead {
/// Count a read starting in `reads`.
pub(super) fn start(reads: &std::sync::Arc<std::sync::atomic::AtomicUsize>) -> Self {
reads.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Self(reads.clone())
}
}
#[cfg(target_arch = "wasm32")]
impl Drop for WebRead {
/// The read is over, whether its file arrived or not.
fn drop(&mut self) {
self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
}
}
+13
View File
@@ -328,6 +328,19 @@ pub struct PdfCraftApp {
/// The user closed the right panel, so opening a document leaves it closed instead of
/// showing its comments or bookmarks (#1590). Remembered across restarts.
pub right_panel_closed: bool,
/// A portfolio that just opened and the name-tree key of the attachment it presents first
/// (`/Collection /D`), opened by [`Self::present_portfolios`] on the next frame while the
/// portfolio is still the active tab.
portfolio_initial: Option<(DocId, String)>,
/// The document whose tab [`Self::present_portfolios`] last saw active.
portfolio_seen: Option<DocId>,
/// Browser file reads still under way (drops, the web file picker): each file opens in front
/// when it arrives.
#[cfg(target_arch = "wasm32")]
web_reads: std::sync::Arc<std::sync::atomic::AtomicUsize>,
/// A portfolio's file list is showing in place of this panel (`None`: the panel was
/// closed), given back when its tab is left.
portfolio_panel: Option<Option<RightPanel>>,
pub quick_tool: QuickTool,
/// The tool to go back to when Space, held for a temporary Hand, is released.
space_hand: Option<QuickTool>,
+1
View File
@@ -63,6 +63,7 @@ mod pdf_links;
mod pickers;
mod pinned_tools;
mod pointer;
mod portfolio;
mod preferences_layout;
mod prepare;
mod print;
+277
View File
@@ -0,0 +1,277 @@
//! A PDF portfolio (`/Collection`, ISO 32000-2 §7.11.6) opens on its documents, not on the cover
//! sheet its pages are (#1652): the document `/D` names opens in a tab of its own, and the
//! portfolio's tab shows its file list even with the right panel closed.
use egui_kittest::Harness;
use pdfcraft_ui_egui::{PdfCraftApp, RightPanel};
/// A one-page PDF.
const PAGE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj
trailer << /Root 1 0 R >>
%%EOF";
/// A one-page PDF with a comment on its page.
const NOTED: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] >> endobj
4 0 obj << /Type /Annot /Subtype /Text /Rect [20 160 40 180] /Contents (Sign here) /T (HR) >> endobj
trailer << /Root 1 0 R >>
%%EOF";
/// [`PAGE`] encrypted, opening with the password `pw`.
fn locked() -> Vec<u8> {
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(PAGE.to_vec())).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "pw",
owner_password: "owner",
permissions: -1,
encrypt_metadata: true,
seed: [4; 32],
})
.unwrap();
pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()
}
/// A one-page portfolio of `files` (name, bytes), with `catalog` (its `/Collection` and any other
/// entries) in its catalog.
fn portfolio(catalog: &str, files: &[(&str, &[u8])]) -> Vec<u8> {
let names: String = files.iter().enumerate().map(|(i, (name, _))| format!("({name}) {} 0 R ", 5 + 2 * i)).collect();
let mut objs: Vec<Vec<u8>> = vec![
format!("<< /Type /Catalog /Pages 2 0 R {catalog} /Names << /EmbeddedFiles << /Names [{names}] >> >> >>").into_bytes(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> >>".to_vec(),
];
for (i, (name, data)) in files.iter().enumerate() {
objs.push([format!("<< /Type /EmbeddedFile /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat());
objs.push(format!("<< /Type /Filespec /F ({name}) /UF ({name}) /EF << /F {} 0 R >> >>", 4 + 2 * i).into_bytes());
}
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
out.extend_from_slice(o);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
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{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// The app with `pdf` opened as `portfolio.pdf`, the reader keeping the right panel closed or not.
fn opened(pdf: Vec<u8>, panel_closed: bool) -> Harness<'static, PdfCraftApp> {
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 = panel_closed;
app.open_bytes("portfolio.pdf", None, pdf.clone()).unwrap();
app
});
h.run_steps(4);
h
}
/// The open tabs' names, and the active one's.
fn tabs(h: &Harness<'static, PdfCraftApp>) -> (Vec<String>, Option<String>) {
let s = h.state();
let name = |i: usize| s.views.get(i).and_then(|v| s.session.get(v.id)).map(|d| d.name.clone());
((0..s.views.len()).filter_map(name).collect(), s.active.and_then(name))
}
/// Make tab `index` the active one.
fn select(h: &mut Harness<'static, PdfCraftApp>, index: usize) {
h.state_mut().active = Some(index);
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);
// The document `/D` names is presented, in a tab of its own, with the reader's own panels.
assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), "form.pdf".into()], Some("form.pdf".into())));
assert_eq!(h.state().right, None);
// The portfolio's tab shows its files, without changing the reader's preference.
select(&mut h, 0);
assert_eq!(h.state().right, Some(RightPanel::Attachments));
assert!(h.state().right_panel_closed);
// Leaving it gives back the closed panel; coming back shows the files again.
select(&mut h, 1);
assert_eq!(h.state().right, None);
select(&mut h, 0);
assert_eq!(h.state().right, Some(RightPanel::Attachments));
// The reader can still close the list on the portfolio's tab.
h.state_mut().choose_right_panel(None);
h.run_steps(2);
assert_eq!(h.state().right, None);
// A panel the reader had open comes back once they leave the portfolio's tab.
select(&mut h, 1);
h.state_mut().choose_right_panel(Some(RightPanel::Bookmarks));
select(&mut h, 0);
assert_eq!(h.state().right, Some(RightPanel::Attachments));
select(&mut h, 1);
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
// reader keeps the right panel open: the document it presents opens with the panels any other
// would, here on its comments, not with the portfolio's file list.
let mut h = opened(portfolio("/PageMode /UseAttachments /Collection << /D (form.pdf) >>", &[("intro.pdf", PAGE), ("form.pdf", NOTED)]), false);
assert_eq!(tabs(&h).1.as_deref(), Some("form.pdf"));
assert_eq!(h.state().right, Some(RightPanel::Comments));
select(&mut h, 0);
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));
}
/// A password asked for a file opened together with the portfolio stays asked: the initial
/// document waits for the answer, then gives way to that file, or opens once the prompt is
/// cancelled.
#[test]
fn a_password_asked_for_a_file_opened_with_the_portfolio_stays_asked() {
for answer in [Some("pw".to_string()), None] {
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.open_bytes("portfolio.pdf", None, pdf.clone()).unwrap();
app.open_bytes("locked.pdf", None, locked()).unwrap();
app
});
h.run_steps(4);
assert!(h.state().password_prompt.is_some(), "the prompt for locked.pdf is still up");
assert_eq!(tabs(&h).0, ["portfolio.pdf"]);
let opened = if answer.is_some() { "locked.pdf" } else { "form.pdf" };
// The answered file opens in the background worker.
h.state_mut().submit_password(answer);
crate::support::wait_until(&mut h, "a second tab", |app| app.views.len() == 2);
h.run_steps(4);
assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), opened.into()], Some(opened.into())));
}
}
/// An attachment deleted while the initial document waits (here for a password asked for a file
/// opened with the portfolio) doesn't change which document opens.
#[test]
fn a_deleted_attachment_doesnt_change_which_document_opens() {
let pdf = portfolio("/Collection << /D (form.pdf) >>", &[("intro.pdf", PAGE), ("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.open_bytes("portfolio.pdf", None, pdf.clone()).unwrap();
app.open_bytes("locked.pdf", None, locked()).unwrap();
app
});
h.run_steps(4);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::DeleteAttachment { key: "intro.pdf".into() }));
h.state_mut().submit_password(None);
h.run_steps(4);
assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), "form.pdf".into()], Some("form.pdf".into())));
}
/// Leaving the portfolio's tab in Read mode: Read mode gives back the panel the file list
/// replaced, not the list, on the tab that is active when it ends.
#[test]
fn read_mode_gives_back_the_panel_the_file_list_replaced() {
let mut h = opened(portfolio("/Collection << /View /D >>", &[("form.pdf", PAGE)]), true);
h.state_mut().open_bytes("other.pdf", None, PAGE.to_vec()).unwrap();
h.run_steps(2);
select(&mut h, 0);
assert_eq!(h.state().right, Some(RightPanel::Attachments));
assert!(h.state_mut().execute("view.read_mode"));
h.run_steps(2);
assert_eq!(h.state().right, None);
select(&mut h, 1);
assert!(h.state_mut().execute("view.read_mode"));
h.run_steps(2);
assert_eq!(h.state().right, None, "the panel was closed before the list showed");
}
/// The initial document is a PDF by its bytes, not its name.
#[test]
fn an_initial_pdf_named_without_pdf_opens() {
let h = opened(portfolio("/Collection << /D (Form) >>", &[("Form", PAGE)]), true);
let (names, active) = tabs(&h);
assert_eq!(names.len(), 2, "{names:?}");
assert_ne!(active.as_deref(), Some("portfolio.pdf"));
}
/// A panel the reader picks in Read mode on the portfolio's tab stays when they switch tabs.
#[test]
fn a_panel_picked_in_read_mode_stays_when_the_tab_changes() {
let mut h = opened(portfolio("/Collection << /View /D >>", &[("form.pdf", PAGE)]), true);
h.state_mut().open_bytes("other.pdf", None, PAGE.to_vec()).unwrap();
h.run_steps(2);
select(&mut h, 0);
assert!(h.state_mut().execute("view.read_mode"));
h.run_steps(2);
h.state_mut().choose_right_panel(Some(RightPanel::Attachments));
select(&mut h, 1);
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);
assert_eq!(tabs(&h).1.as_deref(), Some("form.pdf"));
select(&mut h, 0);
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
// opens nothing either.
for collection in ["/Collection << /View /D >>", "/Collection << /D (notes.txt) >>"] {
let h = opened(portfolio(collection, &[("notes.txt", b"hello"), ("form.pdf", PAGE)]), true);
assert_eq!(tabs(&h), (vec!["portfolio.pdf".into()], Some("portfolio.pdf".into())), "{collection}");
assert_eq!(h.state().right, Some(RightPanel::Attachments), "{collection}");
}
}
/// 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
// one opens one tab, not a chain of them.
let inner = portfolio("/Collection << /D (deep.pdf) >>", &[("deep.pdf", PAGE)]);
let h = opened(portfolio("/Collection << /D (inner.pdf) >>", &[("inner.pdf", &inner)]), true);
assert_eq!(tabs(&h), (vec!["portfolio.pdf".into(), "inner.pdf".into()], Some("inner.pdf".into())));
// Its own tab still shows its files.
assert_eq!(h.state().right, Some(RightPanel::Attachments));
}
+4 -4
View File
@@ -3511,11 +3511,11 @@ title = "View PDF Portfolios as a file list"
area = "D"
tier = "P2"
milestone = "M12"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
tools = ["doc_info"]
evidence = ["crates/render/src/inspect/portfolio_tests.rs::a_portfolio_presents_the_attachment_its_d_names", "crates/ui-egui/tests/it/portfolio.rs::a_portfolio_opens_its_initial_document_and_shows_its_files_with_the_panel_closed", "crates/ui-egui/tests/it/portfolio.rs::a_portfolio_that_keeps_its_list_hidden_still_presents_its_initial_document", "crates/ui-egui/tests/it/portfolio.rs::a_portfolio_without_an_initial_document_shows_its_files_over_the_cover", "crates/ui-egui/tests/it/portfolio.rs::a_portfolio_inside_the_initial_document_waits_for_the_reader", "crates/automation/tests/it/portfolio_info.rs::doc_info_names_the_document_a_portfolio_presents_first"]
notes = "A document with a /Collection (ISO 32000-2 §7.11.6) opens the PDF its /D names in a tab of its own, and the portfolio's tab shows its files in the Attachments panel even with the right panel closed (not with /View /H, which keeps the list for the reader to open), each PDF opening in a tab from there; doc_info reports the portfolio, its initial document and view. Not yet: the files in a details or tile view of their own with the /Schema columns, /Sort and thumbnails, /Folders, previewing a file inside the portfolio's window, and saving edits back into the portfolio"
[[feature]]
id = "organize.portfolio-extract"