diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 36bf4648f..e53b6ed18 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (192) +## In this repository (193) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -21,6 +21,7 @@ Every asset PrintCraft includes, bundles or uses to build its published material | `assets/icons/bold.svg` | Lucide icon "bold" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bold.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/book-open.svg` | Lucide icon "book-open" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/book-open.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/bookmark.svg` | Lucide icon "bookmark" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | +| `assets/icons/bookmark-plus.svg` | Lucide icon "bookmark-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/braces.svg` | Lucide icon "braces" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/braces.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/brush-cleaning.svg` | Lucide icon "brush-cleaning" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush-cleaning.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/brush.svg` | Lucide icon "brush" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/brush.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 7cce3b15c..affc9784f 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -151,6 +151,17 @@ kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "864613d93fddfb9b22368882c00b7895fa2681ceb3ed45c85a1cf2c3d01a3aad" +[[asset]] +path = "assets/icons/bookmark-plus.svg" +title = "Lucide icon \"bookmark-plus\"" +author = "Lucide Contributors (portions Cole Bemis, Feather)" +source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/bookmark-plus.svg)" +licence = "ISC" +licence_file = "assets/icons/LICENSE-lucide.txt" +kind = "icon" +usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" +sha256 = "e111411ef061a708e351caefefa68bd1f8c82533f08f9e20dc6bbc0815d7fc1f" + [[asset]] path = "assets/icons/braces.svg" title = "Lucide icon \"braces\"" diff --git a/assets/icons/bookmark-plus.svg b/assets/icons/bookmark-plus.svg new file mode 100644 index 000000000..d4b893756 --- /dev/null +++ b/assets/icons/bookmark-plus.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 6952e5981..9e36b9ae8 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -160,6 +160,32 @@ impl Automation { json!({ "redone": label, "document": summary(self.doc(&a)?) }) } "command_list" => self.command_list(&a)?, + "bookmark_list" => json!({ "bookmarks": bookmark_tree(&self.doc(&a)?.info.outline, &[]) }), + "bookmark_add" => { + let page = self.page(&a)?; + let parent = a.opt_path("parent")?.unwrap_or_default(); + let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); + let title = a.str("title")?.to_string(); + self.apply(&a, Edit::AddBookmark { parent, index, title, page })? + } + "bookmark_rename" => { + let (path, title) = (a.path("path")?, a.str("title")?.to_string()); + self.apply(&a, Edit::RenameBookmark { path, title })? + } + "bookmark_delete" => { + let path = a.path("path")?; + self.apply(&a, Edit::DeleteBookmark { path })? + } + "bookmark_move" => { + let from = a.path("path")?; + let to_parent = a.opt_path("parent")?.unwrap_or_default(); + let index = a.opt_int("position")?.map_or(usize::MAX, |p| (p.max(1) - 1) as usize); + self.apply(&a, Edit::MoveBookmark { from, to_parent, index })? + } + "bookmark_set_page" => { + let (path, page) = (a.path("path")?, self.page(&a)?); + self.apply(&a, Edit::SetBookmarkPage { path, page })? + } other => return Err(ToolError::UnknownTool(other.into())), }; Ok(vec![Content::Json(out)]) @@ -505,6 +531,18 @@ impl Args<'_> { let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of integers"))?; arr.iter().map(|x| x.as_i64().ok_or_else(|| Self::wrong(key, "an array of integers"))).collect::>>().map(Some) } + /// A 1-based bookmark path → 0-based indices. + fn path(&self, key: &str) -> Result> { + let p = self.opt_path(key)?.ok_or_else(|| Self::missing(key))?; + if p.is_empty() { + return Err(ToolError::InvalidArgs(format!("{key} must not be empty"))); + } + Ok(p) + } + fn opt_path(&self, key: &str) -> Result>> { + let Some(v) = self.opt_ints(key)? else { return Ok(None) }; + v.iter().map(|i| if *i >= 1 { Ok(*i as usize - 1) } else { Err(Self::wrong(key, "1-based positions")) }).collect::>>().map(Some) + } fn strs(&self, key: &str) -> Result> { let v = self.get(key).ok_or_else(|| Self::missing(key))?; let arr = v.as_array().ok_or_else(|| Self::wrong(key, "an array of strings"))?; @@ -512,6 +550,19 @@ impl Args<'_> { } } +/// The bookmark tree as JSON, with 1-based paths and pages. +fn bookmark_tree(items: &[printcraft_render::OutlineItem], parent: &[usize]) -> Vec { + items + .iter() + .enumerate() + .map(|(i, o)| { + let mut path = parent.to_vec(); + path.push(i + 1); + json!({ "path": path, "title": o.title, "page": o.page.map(|p| p + 1), "open": o.open, "children": bookmark_tree(&o.children, &path) }) + }) + .collect() +} + fn one_based(pages: &[i64]) -> Result> { pages .iter() diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index a6f675ab7..6c3da1614 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -30,6 +30,10 @@ fn pages(what: &str) -> Value { json!({ "type": "array", "items": { "type": "integer", "minimum": 1 }, "minItems": 1, "description": format!("1-based page numbers {what}.") }) } +fn path(what: &str) -> Value { + json!({ "type": "array", "items": { "type": "integer", "minimum": 1 }, "description": format!("{what}: 1-based positions from the top level, e.g. [2, 1] = the first child of the second bookmark.") }) +} + fn schema(props: Value, required: &[&str]) -> Value { json!({ "type": "object", "properties": props, "required": required, "additionalProperties": false }) } @@ -144,6 +148,24 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "every": { "type": "integer", "minimum": 1 }, "before": pages("that start a new part"), "out_dir": { "type": "string" } }), &["doc", "out_dir"], )), + t("bookmark_list", "List bookmarks", "The bookmark tree with each bookmark's path, title, target page and open state.") + .ro() + .with(schema(json!({ "doc": doc() }), &["doc"])), + t("bookmark_add", "Add a bookmark", "Add a bookmark that goes to a page, under a parent bookmark (or at the top level), at a position. Undoable.").with(schema( + json!({ "doc": doc(), "title": { "type": "string", "minLength": 1 }, "page": { "type": "integer", "minimum": 1 }, "parent": path("Parent bookmark (omit for the top level)"), "position": { "type": "integer", "minimum": 1, "description": "1-based position among the parent's children (default: last)." } }), + &["doc", "title", "page"], + )), + t("bookmark_rename", "Rename a bookmark", "Change a bookmark's title. Undoable.") + .with(schema(json!({ "doc": doc(), "path": path("The bookmark"), "title": { "type": "string", "minLength": 1 } }), &["doc", "path", "title"])), + t("bookmark_delete", "Delete a bookmark", "Delete a bookmark and the bookmarks under it. Undoable.") + .destructive() + .with(schema(json!({ "doc": doc(), "path": path("The bookmark") }), &["doc", "path"])), + t("bookmark_move", "Move a bookmark", "Move a bookmark (with its children) under another parent, or within its level. Undoable.").with(schema( + json!({ "doc": doc(), "path": path("The bookmark to move"), "parent": path("New parent (omit for the top level)"), "position": { "type": "integer", "minimum": 1, "description": "1-based position among the new parent's children, counted after the bookmark is removed (default: last)." } }), + &["doc", "path"], + )), + t("bookmark_set_page", "Set a bookmark's page", "Point a bookmark at another page. Undoable.") + .with(schema(json!({ "doc": doc(), "path": path("The bookmark"), "page": { "type": "integer", "minimum": 1 } }), &["doc", "path", "page"])), t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])), t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])), t("command_list", "List commands", "Every registered PrintCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 57c6a66b3..9b0716241 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -299,3 +299,31 @@ fn parallel_text_extraction_keeps_page_order_and_follows_edits() { assert_eq!(ok(&mut a, "text_find", json!({ "doc": doc, "query": "page 3" }))["count"], 10); assert_eq!(page_text(&mut a, doc)[2], "Page 4"); } + +#[test] +fn bookmarks_through_tools() { + let dir = workdir("bookmarks"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([])); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Intro", "page": 1 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Body", "page": 2 })); + ok(&mut a, "bookmark_add", json!({ "doc": doc, "title": "Detail", "page": 3, "parent": [2] })); + ok(&mut a, "bookmark_move", json!({ "doc": doc, "path": [1], "parent": [2], "position": 1 })); // Intro under Body + ok(&mut a, "bookmark_rename", json!({ "doc": doc, "path": [1, 2], "title": "Details" })); + ok(&mut a, "bookmark_set_page", json!({ "doc": doc, "path": [1, 1], "page": 3 })); + let list = ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"].clone(); + assert_eq!(list[0]["title"], "Body"); + assert_eq!(list[0]["children"][0]["title"], "Intro"); + assert_eq!(list[0]["children"][0]["page"], 3); + assert_eq!(list[0]["children"][0]["path"], json!([1, 1])); + assert_eq!(list[0]["children"][1]["title"], "Details"); + assert!(matches!(a.call("bookmark_delete", &json!({ "doc": doc, "path": [9] })), Err(ToolError::Failed(_)))); + assert!(matches!(a.call("bookmark_delete", &json!({ "doc": doc, "path": [0] })), Err(ToolError::InvalidArgs(_)))); + ok(&mut a, "bookmark_delete", json!({ "doc": doc, "path": [1, 1] })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" })); + let mut b = auto(&dir); + let re = ok(&mut b, "doc_open", json!({ "path": "marked.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut b, "bookmark_list", json!({ "doc": re }))["bookmarks"].clone(); + assert_eq!((list[0]["title"].as_str(), list[0]["children"][0]["title"].as_str()), (Some("Body"), Some("Details"))); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 54f606b07..0e18fcc82 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -146,6 +146,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("form.fields", "Form fields panel", VIEW, None, Document, "list"), c("protect.properties", "Security properties…", FILE, None, Document, "shield-check"), c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"), + c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"), c("page.rotate", "Rotate pages clockwise", PAGES, None, Assembly, "rotate-cw"), c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"), c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 45f6f4ce8..77c514879 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -170,6 +170,32 @@ pub enum Edit { pages: Option>, at: usize, }, + /// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level). + AddBookmark { + parent: Vec, + index: usize, + title: String, + page: usize, + }, + RenameBookmark { + path: Vec, + title: String, + }, + /// Delete a bookmark and the bookmarks under it. + DeleteBookmark { + path: Vec, + }, + /// Move a bookmark to child `index` of `to_parent` (indices after removing it). + MoveBookmark { + from: Vec, + to_parent: Vec, + index: usize, + }, + /// Point a bookmark at another page. + SetBookmarkPage { + path: Vec, + page: usize, + }, /// Several edits applied as one undoable step (all or nothing). Batch { label: String, @@ -187,6 +213,11 @@ impl Edit { Edit::InsertBlankPage { .. } => "Insert blank page".into(), Edit::SetInfo { key, .. } => format!("Change {key}"), Edit::InsertPagesFrom { name, .. } => format!("Insert pages from {name}"), + Edit::AddBookmark { .. } => "Add bookmark".into(), + Edit::RenameBookmark { .. } => "Rename bookmark".into(), + Edit::DeleteBookmark { .. } => "Delete bookmark".into(), + Edit::MoveBookmark { .. } => "Move bookmark".into(), + Edit::SetBookmarkPage { .. } => "Set bookmark destination".into(), Edit::Batch { label, .. } => label.clone(), } } @@ -199,7 +230,13 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(), | Edit::DeletePages { .. } | Edit::MovePages { .. } | Edit::InsertBlankPage { .. } - | Edit::InsertPagesFrom { .. } => { + | Edit::InsertPagesFrom { .. } + // "Assemble the document: insert, rotate or delete pages and create bookmarks" (Table 22). + | Edit::AddBookmark { .. } + | Edit::RenameBookmark { .. } + | Edit::DeleteBookmark { .. } + | Edit::MoveBookmark { .. } + | Edit::SetBookmarkPage { .. } => { if p.assemble() { Ok(()) } else { @@ -235,6 +272,15 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit) -> Result<(), EditE }; printcraft_organize::import_pages(doc, &src, &pages, *at)?; } + Edit::AddBookmark { parent, index, title, page } => { + printcraft_organize::add_bookmark(doc, parent, *index, title, *page)?; + } + Edit::RenameBookmark { path, title } => printcraft_organize::rename_bookmark(doc, path, title)?, + Edit::DeleteBookmark { path } => printcraft_organize::delete_bookmark(doc, path)?, + Edit::MoveBookmark { from, to_parent, index } => { + printcraft_organize::move_bookmark(doc, from, to_parent, *index)?; + } + Edit::SetBookmarkPage { path, page } => printcraft_organize::set_bookmark_page(doc, path, *page)?, Edit::Batch { edits, .. } => { for e in edits { run_edit(doc, e)?; @@ -271,6 +317,8 @@ pub enum EditError { NotPermitted(&'static str), #[error("{0}")] Organize(#[from] printcraft_organize::OrganizeError), + #[error("{0}")] + Bookmark(#[from] printcraft_organize::OutlineError), #[error("the edited document could not be written: {0}")] Write(String), #[error("the edited document could not be reopened: {0}")] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1ab46f6c7..122115c73 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -330,3 +330,41 @@ fn recovered_documents_reopen_unsaved_at_their_original_path() { assert_eq!(d.info.pages.len(), 1, "the edit survived"); assert!(s2.autosave_snapshots().is_empty(), "already in the recovery store"); } + +fn outline_titles(items: &[printcraft_render::OutlineItem]) -> Vec { + items + .iter() + .map(|o| { + if o.children.is_empty() { + format!("{}→{}", o.title, o.page.map_or(0, |p| p + 1)) + } else { + format!("{}→{}[{}]", o.title, o.page.map_or(0, |p| p + 1), outline_titles(&o.children).join(",")) + } + }) + .collect() +} + +#[test] +fn bookmark_edits_show_in_the_viewer_undo_and_save() { + let (mut s, id) = session_with(3); + let titles = |s: &Session| outline_titles(&s.get(id).unwrap().info.outline); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 0, title: "Start".into(), page: 0 }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![], index: 1, title: "End".into(), page: 2 }).unwrap(); + s.apply(id, Edit::AddBookmark { parent: vec![1], index: 0, title: "Détail".into(), page: 1 }).unwrap(); + // The inspector (an independent parser) sees the same tree and destinations. + assert_eq!(titles(&s), ["Start→1", "End→3[Détail→2]"]); + s.apply(id, Edit::MoveBookmark { from: vec![1, 0], to_parent: vec![], index: 0 }).unwrap(); + s.apply(id, Edit::RenameBookmark { path: vec![2], title: "Finish".into() }).unwrap(); + s.apply(id, Edit::SetBookmarkPage { path: vec![1], page: 1 }).unwrap(); + assert_eq!(titles(&s), ["Détail→2", "Start→2", "Finish→3"]); + assert_eq!(s.get(id).unwrap().can_undo(), Some("Set bookmark destination")); + s.undo(id).unwrap(); + assert_eq!(titles(&s), ["Détail→2", "Start→1", "Finish→3"]); + s.apply(id, Edit::DeleteBookmark { path: vec![0] }).unwrap(); + assert!(matches!(s.apply(id, Edit::DeleteBookmark { path: vec![7] }), Err(EditError::Bookmark(_)))); + + let saved = s.save_bytes(id).unwrap(); + let mut again = Session::new(); + let id2 = again.open("again.pdf", None, saved, None).unwrap(); + assert_eq!(outline_titles(&again.get(id2).unwrap().info.outline), ["Start→1", "Finish→3"]); +} diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index 5914bc531..d257d45ff 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -13,9 +13,13 @@ use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; mod dedupe; mod import; +mod outline; pub use dedupe::dedupe_resources; pub use import::{SplitBy, combine, extract_pages, import_pages, split, split_ranges}; +pub use outline::{ + Bookmark, OutlineError, add_bookmark, bookmarks, delete_bookmark, move_bookmark, rename_bookmark, set_bookmark_open, set_bookmark_page, +}; #[derive(Debug, thiserror::Error, PartialEq)] pub enum OrganizeError { diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs new file mode 100644 index 000000000..6379b143c --- /dev/null +++ b/crates/organize/src/outline.rs @@ -0,0 +1,274 @@ +//! Editing bookmarks (the document outline, ISO 32000-2 §12.3.3), execution plan M4.6. +//! +//! Bookmarks are addressed by their **path**: child indices from the top level, so `[0, 2]` is +//! the third child of the first top-level bookmark. Paths follow `/First`–`/Next` order, which +//! is also the order viewers (and our inspector) list them in. +//! +//! Edits are surgical: they relink only the affected siblings (`/Parent`, `/Prev`, `/Next`, +//! `/First`, `/Last`) and recompute `/Count` values, and an object is rewritten only when one of +//! its values changes. Everything else on an item (colour, style, actions, unknown keys) is kept. +//! Walks are cycle- and size-safe; a broken outline is never followed forever. + +use std::collections::HashSet; + +use printcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; + +use crate::{OrganizeError, walk}; + +/// Hard cap on items visited (broken or hostile outlines). +const MAX_ITEMS: usize = 100_000; + +/// A bookmark as stored in the file. +#[derive(Clone, Debug, PartialEq)] +pub struct Bookmark { + pub obj: ObjRef, + pub title: String, + /// Shown expanded (a positive `/Count`) when it has children. + pub open: bool, + pub children: Vec, +} + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum OutlineError { + #[error("there is no bookmark at {0:?}")] + NoSuchBookmark(Vec), + #[error("a bookmark cannot be moved inside itself")] + IntoItself, + #[error("the title must not be empty")] + EmptyTitle, + #[error("{0}")] + Organize(#[from] OrganizeError), + #[error("{0}")] + Cos(#[from] printcraft_cos::CosError), +} + +type Result = std::result::Result; + +fn outline_root(doc: &Document) -> Option { + let root = doc.root()?; + doc.get(root).as_dict()?.reference(b"Outlines") +} + +/// The children of `parent` (an item or the outline root), in order. Cycle-safe. +fn children_of(doc: &Document, parent: ObjRef, seen: &mut HashSet) -> Vec { + let mut out = Vec::new(); + let mut next = doc.get(parent).as_dict().and_then(|d| d.reference(b"First")); + while let Some(r) = next { + if seen.len() >= MAX_ITEMS || !seen.insert(r) { + break; + } + let item = doc.get(r); + let Some(d) = item.as_dict() else { break }; + out.push(r); + next = d.reference(b"Next"); + } + out +} + +/// The bookmark tree. +pub fn bookmarks(doc: &Document) -> Vec { + let Some(root) = outline_root(doc) else { return Vec::new() }; + let mut seen = HashSet::from([root]); + fn build(doc: &Document, parent: ObjRef, seen: &mut HashSet) -> Vec { + children_of(doc, parent, seen) + .into_iter() + .map(|r| { + let item = doc.get(r); + let d = item.as_dict().cloned().unwrap_or_default(); + let title = d.get(b"Title").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|s| s.to_text())).unwrap_or_default(); + let open = d.int(b"Count").is_some_and(|c| c > 0); + Bookmark { obj: r, title, open, children: build(doc, r, seen) } + }) + .collect() + } + build(doc, root, &mut seen) +} + +/// The object of the bookmark at `path`, and its parent object (the outline root for top-level). +fn resolve_path(doc: &Document, path: &[usize]) -> Result<(ObjRef, ObjRef)> { + let root = outline_root(doc).ok_or_else(|| OutlineError::NoSuchBookmark(path.to_vec()))?; + if path.is_empty() { + return Err(OutlineError::NoSuchBookmark(Vec::new())); + } + let mut parent = root; + let mut seen = HashSet::from([root]); + for (depth, i) in path.iter().enumerate() { + let kids = children_of(doc, parent, &mut seen); + let r = *kids.get(*i).ok_or_else(|| OutlineError::NoSuchBookmark(path.to_vec()))?; + if depth + 1 == path.len() { + return Ok((r, parent)); + } + parent = r; + } + unreachable!("the loop returns on the last index") +} + +/// The object that holds children at `parent_path` (`[]` = the outline root, created if needed). +fn container(doc: &mut Document, parent_path: &[usize]) -> Result { + if parent_path.is_empty() { + if let Some(r) = outline_root(doc) { + return Ok(r); + } + let mut o = Dict::new(); + o.set(b"Type".to_vec(), Object::name("Outlines")); + let r = doc.add(Object::Dict(o)); + let root = doc.root().ok_or(OrganizeError::NoPageTree)?; + doc.update_dict(root, |c| c.set(b"Outlines".to_vec(), Object::Ref(r)))?; + return Ok(r); + } + Ok(resolve_path(doc, parent_path)?.0) +} + +/// Set `key` on the dictionary `r` (or remove it with `None`) only if it changes. +fn put(doc: &mut Document, r: ObjRef, key: &[u8], value: Option) -> Result<()> { + let current = doc.get(r).as_dict().and_then(|d| d.get(key).cloned()); + if current == value { + return Ok(()); + } + doc.update_dict(r, |d| match value { + Some(v) => d.set(key.to_vec(), v), + None => { + d.remove(key); + } + })?; + Ok(()) +} + +/// Make `kids` the children of `parent`, in order: rewires `/Parent`, `/Prev`, `/Next`, +/// `/First` and `/Last`. +fn relink(doc: &mut Document, parent: ObjRef, kids: &[ObjRef]) -> Result<()> { + for (i, k) in kids.iter().enumerate() { + put(doc, *k, b"Parent", Some(Object::Ref(parent)))?; + put(doc, *k, b"Prev", i.checked_sub(1).map(|p| Object::Ref(kids[p])))?; + put(doc, *k, b"Next", kids.get(i + 1).map(|n| Object::Ref(*n)))?; + } + put(doc, parent, b"First", kids.first().map(|k| Object::Ref(*k)))?; + put(doc, parent, b"Last", kids.last().map(|k| Object::Ref(*k)))?; + Ok(()) +} + +/// Recompute every `/Count` (§12.3.3: open items count their visible descendants, closed items +/// the negative of what opening them would show; the root counts all visible items). +fn recount(doc: &mut Document) -> Result<()> { + let Some(root) = outline_root(doc) else { return Ok(()) }; + let mut seen = HashSet::from([root]); + // Returns the number of items visible below `node` when `node` is open. + fn visit(doc: &mut Document, node: ObjRef, is_root: bool, seen: &mut HashSet) -> Result { + let kids = children_of(doc, node, seen); + let mut visible = 0i64; + for k in &kids { + let below = visit(doc, *k, false, seen)?; + let open = doc.get(*k).as_dict().and_then(|d| d.int(b"Count")).is_some_and(|c| c > 0); + visible += 1 + if open { below } else { 0 }; + } + let count = if kids.is_empty() { + None + } else if is_root || doc.get(node).as_dict().and_then(|d| d.int(b"Count")).is_some_and(|c| c > 0) { + Some(visible) + } else { + Some(-visible) + }; + put(doc, node, b"Count", count.map(Object::Int))?; + Ok(visible) + } + visit(doc, root, true, &mut seen)?; + Ok(()) +} + +fn destination(doc: &Document, page: usize) -> Result { + let pages = walk(doc)?; + let (p, _) = pages.get(page).ok_or(OrganizeError::NoSuchPage(page))?; + // `/XYZ null null null`: go to the page, keeping the reader's zoom (what Acrobat writes for + // a new bookmark when the view has no specific position). + Ok(Object::Array(vec![Object::Ref(*p), Object::name("XYZ"), Object::Null, Object::Null, Object::Null])) +} + +/// Add a bookmark titled `title` that goes to `page` (0-based), as child `index` of +/// `parent_path` (`[]` = top level; an index past the end appends). Returns its path. +pub fn add_bookmark(doc: &mut Document, parent_path: &[usize], index: usize, title: &str, page: usize) -> Result> { + if title.trim().is_empty() { + return Err(OutlineError::EmptyTitle); + } + let dest = destination(doc, page)?; + let parent = container(doc, parent_path)?; + let mut kids = children_of(doc, parent, &mut HashSet::from([parent])); + let mut d = Dict::new(); + d.set(b"Title".to_vec(), Object::String(PdfString::text(title))); + d.set(b"Dest".to_vec(), dest); + let item = doc.add(Object::Dict(d)); + let at = index.min(kids.len()); + kids.insert(at, item); + relink(doc, parent, &kids)?; + // Show the new bookmark: open its parent (the root is always open). + if !parent_path.is_empty() && doc.get(parent).as_dict().and_then(|d| d.int(b"Count")).is_none_or(|c| c <= 0) { + put(doc, parent, b"Count", Some(Object::Int(1)))?; + } + recount(doc)?; + let mut path = parent_path.to_vec(); + path.push(at); + Ok(path) +} + +/// Change a bookmark's title. +pub fn rename_bookmark(doc: &mut Document, path: &[usize], title: &str) -> Result<()> { + if title.trim().is_empty() { + return Err(OutlineError::EmptyTitle); + } + let (r, _) = resolve_path(doc, path)?; + put(doc, r, b"Title", Some(Object::String(PdfString::text(title)))) +} + +/// Point a bookmark at `page` (0-based), replacing its destination or GoTo action. +pub fn set_bookmark_page(doc: &mut Document, path: &[usize], page: usize) -> Result<()> { + let dest = destination(doc, page)?; + let (r, _) = resolve_path(doc, path)?; + put(doc, r, b"Dest", Some(dest))?; + put(doc, r, b"A", None) // `/Dest` and `/A` must not both be present (§12.3.3) +} + +/// Remove a bookmark and everything under it. The removed items are unlinked (a full save drops +/// them; an incremental save leaves the old objects unreferenced). +pub fn delete_bookmark(doc: &mut Document, path: &[usize]) -> Result<()> { + let (r, parent) = resolve_path(doc, path)?; + let mut kids = children_of(doc, parent, &mut HashSet::from([parent])); + kids.retain(|k| *k != r); + relink(doc, parent, &kids)?; + recount(doc) +} + +/// Move the bookmark at `from` to child `index` of `to_parent` (counted after removing it from +/// its old place; past the end appends). Returns its new path. +pub fn move_bookmark(doc: &mut Document, from: &[usize], to_parent: &[usize], index: usize) -> Result> { + if to_parent.starts_with(from) { + return Err(OutlineError::IntoItself); + } + let (r, old_parent) = resolve_path(doc, from)?; + let mut old = children_of(doc, old_parent, &mut HashSet::from([old_parent])); + old.retain(|k| *k != r); + relink(doc, old_parent, &old)?; + // Removing `from` shifts later siblings: adjust a target path that runs through them. + let mut to_parent = to_parent.to_vec(); + let depth = from.len() - 1; + if to_parent.len() > depth && to_parent[..depth] == from[..depth] && to_parent[depth] > from[depth] { + to_parent[depth] -= 1; + } + let parent = container(doc, &to_parent)?; + let mut kids = children_of(doc, parent, &mut HashSet::from([parent])); + let at = index.min(kids.len()); + kids.insert(at, r); + relink(doc, parent, &kids)?; + recount(doc)?; + to_parent.push(at); + Ok(to_parent) +} + +/// Expand or collapse a bookmark that has children. +pub fn set_bookmark_open(doc: &mut Document, path: &[usize], open: bool) -> Result<()> { + let (r, _) = resolve_path(doc, path)?; + let count = doc.get(r).as_dict().and_then(|d| d.int(b"Count")).unwrap_or(0); + if count != 0 && (count > 0) != open { + put(doc, r, b"Count", Some(Object::Int(-count)))?; + recount(doc)?; + } + Ok(()) +} diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index 5413a9ee6..47eab051d 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -480,3 +480,71 @@ fn combine_and_insert_share_identical_resources() { let d = full_roundtrip(&d); assert_eq!(labels(&d), ["A1", "A2", "A3", "A1", "A2"]); } + +// ---- bookmarks (M4.6) -------------------------------------------------------------------------- + +fn titles(b: &[crate::Bookmark]) -> Vec { + b.iter().map(|x| if x.children.is_empty() { x.title.clone() } else { format!("{}[{}]", x.title, titles(&x.children).join(",")) }).collect() +} + +fn count_of(doc: &Document, r: ObjRef) -> Option { + doc.get(r).as_dict().and_then(|d| d.int(b"Count")) +} + +#[test] +fn bookmarks_add_rename_move_delete() { + let mut d = doc_a(); + assert!(crate::bookmarks(&d).is_empty()); + assert_eq!(crate::add_bookmark(&mut d, &[], 0, "Intro", 0).unwrap(), vec![0]); + assert_eq!(crate::add_bookmark(&mut d, &[], 9, "Results", 2).unwrap(), vec![1]); + assert_eq!(crate::add_bookmark(&mut d, &[1], 0, "Table", 1).unwrap(), vec![1, 0]); + crate::add_bookmark(&mut d, &[1], 1, "Chart", 2).unwrap(); + assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Table,Chart]"]); + + crate::rename_bookmark(&mut d, &[1, 1], "Figure 1").unwrap(); + assert_eq!(crate::move_bookmark(&mut d, &[1, 1], &[], 0).unwrap(), vec![0]); + assert_eq!(titles(&crate::bookmarks(&d)), ["Figure 1", "Intro", "Results[Table]"]); + // Into a later sibling: the target path is adjusted for the removal. + assert_eq!(crate::move_bookmark(&mut d, &[0], &[2], 9).unwrap(), vec![1, 1]); + assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Table,Figure 1]"]); + assert_eq!(crate::move_bookmark(&mut d, &[1], &[1, 0], 0), Err(crate::OutlineError::IntoItself)); + crate::delete_bookmark(&mut d, &[1, 0]).unwrap(); + assert_eq!(titles(&crate::bookmarks(&d)), ["Intro", "Results[Figure 1]"]); + + // Counts: the root counts visible items; open items count their visible descendants. + let b = crate::bookmarks(&d); + let root = d.get(d.root().unwrap()).as_dict().unwrap().reference(b"Outlines").unwrap(); + assert_eq!(count_of(&d, root), Some(3)); + assert_eq!(count_of(&d, b[1].obj), Some(1)); + crate::set_bookmark_open(&mut d, &[1], false).unwrap(); + assert_eq!((count_of(&d, b[1].obj), count_of(&d, root)), (Some(-1), Some(2))); + + // Destinations point at the chosen page; errors are specific. + crate::set_bookmark_page(&mut d, &[0], 2).unwrap(); + let dest = d.get(b[0].obj).as_dict().unwrap().get(b"Dest").cloned().unwrap(); + let pages = crate::walk(&d).unwrap(); + assert_eq!(dest.as_array().unwrap()[0], Object::Ref(pages[2].0)); + assert_eq!(crate::rename_bookmark(&mut d, &[5], "x"), Err(crate::OutlineError::NoSuchBookmark(vec![5]))); + assert_eq!(crate::rename_bookmark(&mut d, &[0], " "), Err(crate::OutlineError::EmptyTitle)); + assert!(crate::add_bookmark(&mut d, &[], 0, "Nowhere", 7).is_err()); + + // Everything survives a save and reopen. + let back = full_roundtrip(&d); + assert_eq!(titles(&crate::bookmarks(&back)), ["Intro", "Results[Figure 1]"]); +} + +#[test] +fn bookmark_edits_keep_unknown_keys_and_touch_few_objects() { + let mut d = doc_a(); + crate::add_bookmark(&mut d, &[], 0, "One", 0).unwrap(); + crate::add_bookmark(&mut d, &[], 1, "Two", 1).unwrap(); + let first = crate::bookmarks(&d)[0].obj; + d.update_dict(first, |x| x.set(b"C".to_vec(), Object::Array(vec![Object::Real(1.0), Object::Int(0), Object::Int(0)]))).unwrap(); + let bytes = std::sync::Arc::new(write_full(&d, &SaveOptions::default()).unwrap()); + let mut d = Document::open(bytes).unwrap(); + crate::rename_bookmark(&mut d, &[0], "Uno").unwrap(); + let b = crate::bookmarks(&d); + assert_eq!(b[0].title, "Uno"); + assert!(d.get(b[0].obj).as_dict().unwrap().get(b"C").is_some(), "colour kept"); + assert_eq!(d.modified_objects(), vec![b[0].obj.num], "a rename rewrites only that item"); +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 840cf7251..a1642b46e 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -48,6 +48,7 @@ impl PrintCraftApp { } "file.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Description)), "protect.properties" => self.dialog = Some(Dialog::Properties(PropsTab::Security)), + "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), "edit.undo" => self.undo(), "edit.redo" => self.redo(), "edit.find" => { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 34985e99e..37011517a 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -273,3 +273,57 @@ pub(crate) fn download(name: &str, bytes: &[u8]) -> Result<(), String> { let _ = web_sys::Url::revoke_object_url(&url); Ok(()) } + +impl PrintCraftApp { + /// Carry out a Bookmarks-panel action as an undoable edit. + pub fn bookmark_action(&mut self, action: crate::panels::BmAction) { + use crate::panels::BmAction as A; + let Some((i, id)) = self.active_ids() else { return }; + let current = self.views[i].current; + let parent_of = |p: &[usize]| p[..p.len() - 1].to_vec(); + let edit = match action { + A::New => { + let n = self.session.get(id).map_or(0, |d| d.info.outline.len()); + self.apply_edit(Edit::AddBookmark { parent: vec![], index: n, title: "Untitled".into(), page: current }); + // Like Acrobat: the new bookmark starts in rename mode. + self.bookmark_rename = Some((vec![n], "Untitled".into())); + self.right = Some(crate::RightPanel::Bookmarks); + return; + } + A::StartRename(path) => { + let title = self.session.get(id).and_then(|d| bookmark_at(&d.info.outline, &path)).map(|b| b.title.clone()).unwrap_or_default(); + self.bookmark_rename = Some((path, title)); + return; + } + A::Rename(path, title) => Edit::RenameBookmark { path, title }, + A::SetToCurrentPage(path) => Edit::SetBookmarkPage { path, page: current }, + A::Delete(path) => Edit::DeleteBookmark { path }, + A::MoveUp(path) => { + let at = path[path.len() - 1].saturating_sub(1); + Edit::MoveBookmark { to_parent: parent_of(&path), from: path, index: at } + } + A::MoveDown(path) => { + let at = path[path.len() - 1] + 1; + Edit::MoveBookmark { to_parent: parent_of(&path), from: path, index: at } + } + A::Indent(path) => { + let mut to_parent = parent_of(&path); + to_parent.push(path[path.len() - 1].saturating_sub(1)); + Edit::MoveBookmark { from: path, to_parent, index: usize::MAX } + } + A::Outdent(path) => { + let parent = parent_of(&path); + let grand = parent_of(&parent); + let at = parent[parent.len() - 1] + 1; + Edit::MoveBookmark { from: path, to_parent: grand, index: at } + } + }; + self.apply_edit(edit); + } +} + +fn bookmark_at<'a>(items: &'a [printcraft_render::OutlineItem], path: &[usize]) -> Option<&'a printcraft_render::OutlineItem> { + let (first, rest) = path.split_first()?; + let item = items.get(*first)?; + if rest.is_empty() { Some(item) } else { bookmark_at(&item.children, rest) } +} diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 0cb3a23d8..83923b82b 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -15,6 +15,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("bold", include_bytes!("../../../assets/icons/bold.svg")), ("book-open", include_bytes!("../../../assets/icons/book-open.svg")), ("bookmark", include_bytes!("../../../assets/icons/bookmark.svg")), + ("bookmark-plus", include_bytes!("../../../assets/icons/bookmark-plus.svg")), ("braces", include_bytes!("../../../assets/icons/braces.svg")), ("brush-cleaning", include_bytes!("../../../assets/icons/brush-cleaning.svg")), ("brush", include_bytes!("../../../assets/icons/brush.svg")), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 99cb86b11..2270a371f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -151,6 +151,8 @@ pub struct PrintCraftApp { fonts_ready: bool, /// The UI control channel, when enabled (`--control`; off by default). control: Option, + /// A bookmark being renamed in the Bookmarks panel: (path, text so far). + pub bookmark_rename: Option<(Vec, String)>, } impl Default for PrintCraftApp { @@ -198,6 +200,7 @@ impl PrintCraftApp { styled: false, fonts_ready: false, control: None, + bookmark_rename: None, } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index fcad6f8f2..ddbe3fe97 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -181,6 +181,9 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { let mut close = false; let mut toggle_layer: Option<(usize, bool)> = None; let mut attachment_action: Option<(usize, bool)> = None; // (index, open instead of save) + let mut bm_action: Option = None; + let mut bm_rename = app.bookmark_rename.clone(); + let bm_editable = app.session.get(id).is_some_and(|d| d.allows_assembly() && d.read_only_reason.is_none()); { let Some(doc) = app.session.get(id) else { return }; let info = &doc.info; @@ -213,6 +216,12 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { if icons::button(ui, "x", 26.0, false, "Close").clicked() { close = true; } + if panel == RightPanel::Bookmarks + && bm_editable + && icons::button(ui, "bookmark-plus", 26.0, false, "New bookmark (⌘B)").clicked() + { + bm_action = Some(BmAction::New); + } }); }); ui.add_space(6.0); @@ -222,8 +231,15 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { if info.outline.is_empty() { empty(ui, &t, "bookmark", "This document has no bookmarks."); } - for item in &info.outline { - outline_item(ui, &t, info, item, 0, &mut nav); + let mut ctx = OutlineCtx { + nav: &mut nav, + action: &mut bm_action, + rename: &mut bm_rename, + editable: bm_editable, + current: view.current, + }; + for (i, item) in info.outline.iter().enumerate() { + outline_item(ui, &t, info, item, &[i], info.outline.len(), &mut ctx); } } RightPanel::Pages => pages(ui, &t, info, view, &mut nav), @@ -292,6 +308,10 @@ pub fn right_panel(app: &mut PrintCraftApp, ui: &mut egui::Ui) { if close { app.right = None; } + app.bookmark_rename = bm_rename; + if let Some(a) = bm_action { + app.bookmark_action(a); + } if let Some((ai, open)) = attachment_action { app.attachment_action(id, ai, open); } @@ -450,40 +470,124 @@ fn comment_card(ui: &mut egui::Ui, t: &Tokens, a: &Annotation, replies: &[&Annot click } -fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineItem, depth: usize, nav: &mut Option