mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
M4.6: bookmark editing: organize outline API (add/rename/move/delete/set page, surgical relinking, /Count recompute), undoable engine edits, 6 automation tools, Bookmarks panel (⌘B new + inline rename, context menu: rename, set page, move, indent/outdent, delete), Lucide bookmark-plus icon (attributed)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ebfe058a9c
commit
0ac2908195
18 files changed
+868
-33
No files matched your search
+2
-1
@@ -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 |
|
||||
|
||||
@@ -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\""
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-bookmark-plus"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 7v6" />
|
||||
<path d="M15 10H9" />
|
||||
<path d="M17 3a2 2 0 0 1 2 2v15a1 1 0 0 1-1.496.868l-4.512-2.578a2 2 0 0 0-1.984 0l-4.512 2.578A1 1 0 0 1 5 20V5a2 2 0 0 1 2-2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -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::<Result<Vec<_>>>().map(Some)
|
||||
}
|
||||
/// A 1-based bookmark path → 0-based indices.
|
||||
fn path(&self, key: &str) -> Result<Vec<usize>> {
|
||||
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<Option<Vec<usize>>> {
|
||||
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::<Result<Vec<_>>>().map(Some)
|
||||
}
|
||||
fn strs(&self, key: &str) -> Result<Vec<&str>> {
|
||||
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<Value> {
|
||||
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<Vec<usize>> {
|
||||
pages
|
||||
.iter()
|
||||
|
||||
@@ -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<ToolDef> {
|
||||
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.")
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -170,6 +170,32 @@ pub enum Edit {
|
||||
pages: Option<Vec<usize>>,
|
||||
at: usize,
|
||||
},
|
||||
/// Add a bookmark to `page` as child `index` of the bookmark at `parent` (`[]` = top level).
|
||||
AddBookmark {
|
||||
parent: Vec<usize>,
|
||||
index: usize,
|
||||
title: String,
|
||||
page: usize,
|
||||
},
|
||||
RenameBookmark {
|
||||
path: Vec<usize>,
|
||||
title: String,
|
||||
},
|
||||
/// Delete a bookmark and the bookmarks under it.
|
||||
DeleteBookmark {
|
||||
path: Vec<usize>,
|
||||
},
|
||||
/// Move a bookmark to child `index` of `to_parent` (indices after removing it).
|
||||
MoveBookmark {
|
||||
from: Vec<usize>,
|
||||
to_parent: Vec<usize>,
|
||||
index: usize,
|
||||
},
|
||||
/// Point a bookmark at another page.
|
||||
SetBookmarkPage {
|
||||
path: Vec<usize>,
|
||||
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}")]
|
||||
|
||||
@@ -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<String> {
|
||||
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"]);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Bookmark>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum OutlineError {
|
||||
#[error("there is no bookmark at {0:?}")]
|
||||
NoSuchBookmark(Vec<usize>),
|
||||
#[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<T> = std::result::Result<T, OutlineError>;
|
||||
|
||||
fn outline_root(doc: &Document) -> Option<ObjRef> {
|
||||
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<ObjRef>) -> Vec<ObjRef> {
|
||||
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<Bookmark> {
|
||||
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<ObjRef>) -> Vec<Bookmark> {
|
||||
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<ObjRef> {
|
||||
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<Object>) -> 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<ObjRef>) -> Result<i64> {
|
||||
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<Object> {
|
||||
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<Vec<usize>> {
|
||||
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<Vec<usize>> {
|
||||
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(())
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<i64> {
|
||||
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");
|
||||
}
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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")),
|
||||
|
||||
@@ -151,6 +151,8 @@ pub struct PrintCraftApp {
|
||||
fonts_ready: bool,
|
||||
/// The UI control channel, when enabled (`--control`; off by default).
|
||||
control: Option<control::Control>,
|
||||
/// A bookmark being renamed in the Bookmarks panel: (path, text so far).
|
||||
pub bookmark_rename: Option<(Vec<usize>, String)>,
|
||||
}
|
||||
|
||||
impl Default for PrintCraftApp {
|
||||
@@ -198,6 +200,7 @@ impl PrintCraftApp {
|
||||
styled: false,
|
||||
fonts_ready: false,
|
||||
control: None,
|
||||
bookmark_rename: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+135
-31
@@ -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<BmAction> = 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<Nav>) {
|
||||
/// What the user asked to do with a bookmark (applied after the panel is drawn).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum BmAction {
|
||||
/// New "Untitled" bookmark for the current page, after the selected one (⌘B).
|
||||
New,
|
||||
StartRename(Vec<usize>),
|
||||
Rename(Vec<usize>, String),
|
||||
SetToCurrentPage(Vec<usize>),
|
||||
Delete(Vec<usize>),
|
||||
MoveUp(Vec<usize>),
|
||||
MoveDown(Vec<usize>),
|
||||
/// Make it the last child of the bookmark above it.
|
||||
Indent(Vec<usize>),
|
||||
/// Move it out to follow its parent.
|
||||
Outdent(Vec<usize>),
|
||||
}
|
||||
|
||||
struct OutlineCtx<'a> {
|
||||
nav: &'a mut Option<Nav>,
|
||||
action: &'a mut Option<BmAction>,
|
||||
rename: &'a mut Option<(Vec<usize>, String)>,
|
||||
editable: bool,
|
||||
current: usize,
|
||||
}
|
||||
|
||||
fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineItem, path: &[usize], siblings: usize, cx: &mut OutlineCtx<'_>) {
|
||||
let depth = path.len() - 1;
|
||||
let indent = depth as f32 * 16.0;
|
||||
let id = ui.id().with(("outline", depth, item.title.as_str(), item.page));
|
||||
let id = ui.id().with(("outline", path));
|
||||
let mut open = ui.data(|d| d.get_temp::<bool>(id)).unwrap_or(item.open || depth == 0);
|
||||
let font = if depth == 0 { theme::medium(13.0) } else { theme::regular(13.0) };
|
||||
let wrap_w = (ui.available_width() - indent - 20.0 - 36.0).max(60.0);
|
||||
let galley = ui.fonts_mut(|f| f.layout(item.title.clone(), font, t.text, wrap_w));
|
||||
let h = (galley.size().y + 12.0).max(28.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &item.title));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
|
||||
}
|
||||
let x0 = rect.left() + indent;
|
||||
if !item.children.is_empty() {
|
||||
let tri = Rect::from_min_size(pos2(x0, rect.top() + 6.0), vec2(16.0, 16.0));
|
||||
icons::paint(ui, tri, if open { "chevron-down" } else { "chevron-right" }, 14.0, t.text_muted);
|
||||
if ui.interact(tri, id.with("tri"), Sense::click()).clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(id, open));
|
||||
let x_text = indent + 20.0;
|
||||
if let Some((rpath, text)) = cx.rename.as_mut()
|
||||
&& rpath.as_slice() == path
|
||||
{
|
||||
// Inline rename: Enter commits, Escape cancels.
|
||||
let mut commit = None;
|
||||
let mut cancel = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(x_text);
|
||||
let edit = egui::TextEdit::singleline(text).desired_width(ui.available_width() - 8.0).id(id.with("rename"));
|
||||
let resp = ui.add(edit);
|
||||
if !resp.has_focus() && !resp.lost_focus() {
|
||||
resp.request_focus();
|
||||
}
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
cancel = true;
|
||||
} else if resp.lost_focus() {
|
||||
commit = Some(text.trim().to_string());
|
||||
}
|
||||
});
|
||||
if cancel {
|
||||
*cx.rename = None;
|
||||
} else if let Some(title) = commit {
|
||||
*cx.rename = None;
|
||||
if !title.is_empty() && title != item.title {
|
||||
*cx.action = Some(BmAction::Rename(path.to_vec(), title));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let font = if depth == 0 { theme::medium(13.0) } else { theme::regular(13.0) };
|
||||
let wrap_w = (ui.available_width() - indent - 20.0 - 36.0).max(60.0);
|
||||
let galley = ui.fonts_mut(|f| f.layout(item.title.clone(), font, t.text, wrap_w));
|
||||
let h = (galley.size().y + 12.0).max(28.0);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), h), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &item.title));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover);
|
||||
}
|
||||
let x0 = rect.left() + indent;
|
||||
if !item.children.is_empty() {
|
||||
let tri = Rect::from_min_size(pos2(x0, rect.top() + 6.0), vec2(16.0, 16.0));
|
||||
icons::paint(ui, tri, if open { "chevron-down" } else { "chevron-right" }, 14.0, t.text_muted);
|
||||
if ui.interact(tri, id.with("tri"), Sense::click()).clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(id, open));
|
||||
}
|
||||
}
|
||||
ui.painter().galley(pos2(x0 + 20.0, rect.top() + 6.0), galley, t.text);
|
||||
if let Some(p) = item.page
|
||||
&& let Some(page) = info.pages.get(p)
|
||||
{
|
||||
ui.painter().text(rect.right_top() + vec2(-6.0, 14.0), Align2::RIGHT_CENTER, &page.label, theme::regular(11.0), t.text_faint);
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let Some(p) = item.page
|
||||
{
|
||||
*cx.nav = Some(Nav::Page(p));
|
||||
}
|
||||
if resp.double_clicked() && cx.editable {
|
||||
*cx.action = Some(BmAction::StartRename(path.to_vec()));
|
||||
}
|
||||
if cx.editable {
|
||||
let i = *path.last().expect("non-empty path");
|
||||
let current = cx.current;
|
||||
resp.context_menu(|ui| {
|
||||
let mut pick = |ui: &mut egui::Ui, label: &str, enabled: bool, a: BmAction| {
|
||||
if ui.add_enabled(enabled, egui::Button::new(label)).clicked() {
|
||||
*cx.action = Some(a);
|
||||
ui.close();
|
||||
}
|
||||
};
|
||||
pick(ui, "Rename", true, BmAction::StartRename(path.to_vec()));
|
||||
pick(ui, &format!("Set to current page ({})", current + 1), true, BmAction::SetToCurrentPage(path.to_vec()));
|
||||
ui.separator();
|
||||
pick(ui, "Move up", i > 0, BmAction::MoveUp(path.to_vec()));
|
||||
pick(ui, "Move down", i + 1 < siblings, BmAction::MoveDown(path.to_vec()));
|
||||
pick(ui, "Indent", i > 0, BmAction::Indent(path.to_vec()));
|
||||
pick(ui, "Outdent", depth > 0, BmAction::Outdent(path.to_vec()));
|
||||
ui.separator();
|
||||
pick(ui, "Delete", true, BmAction::Delete(path.to_vec()));
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.painter().galley(pos2(x0 + 20.0, rect.top() + 6.0), galley, t.text);
|
||||
if let Some(p) = item.page {
|
||||
ui.painter().text(rect.right_top() + vec2(-6.0, 14.0), Align2::RIGHT_CENTER, &info.pages[p].label, theme::regular(11.0), t.text_faint);
|
||||
}
|
||||
if resp.clicked()
|
||||
&& let Some(p) = item.page
|
||||
{
|
||||
*nav = Some(Nav::Page(p));
|
||||
}
|
||||
if open {
|
||||
for c in &item.children {
|
||||
outline_item(ui, t, info, c, depth + 1, nav);
|
||||
for (ci, c) in item.children.iter().enumerate() {
|
||||
let mut p = path.to_vec();
|
||||
p.push(ci);
|
||||
outline_item(ui, t, info, c, &p, item.children.len(), cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Editing bookmarks in the Bookmarks panel (M4.6), driven like a user would.
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use printcraft_ui_egui::PrintCraftApp;
|
||||
|
||||
const PAGES: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||||
5 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
fn harness() -> Harness<'static, PrintCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.open_bytes("pages.pdf", None, PAGES.to_vec()).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("panel", "bookmarks").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
h
|
||||
}
|
||||
|
||||
fn outline(h: &Harness<'static, PrintCraftApp>) -> Vec<(String, Option<usize>, usize)> {
|
||||
let id = h.state().views[0].id;
|
||||
fn flat(items: &[printcraft_render::OutlineItem], depth: usize, out: &mut Vec<(String, Option<usize>, usize)>) {
|
||||
for i in items {
|
||||
out.push((i.title.clone(), i.page, depth));
|
||||
flat(&i.children, depth + 1, out);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
flat(&h.state().session.get(id).unwrap().info.outline, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The inline rename editor (the text field, not its inner text run).
|
||||
fn field<'a>(h: &'a Harness<'static, PrintCraftApp>, value: &'a str) -> egui_kittest::Node<'a> {
|
||||
h.query_all_by_value(value).next().unwrap_or_else(|| panic!("no editor showing {value:?}"))
|
||||
}
|
||||
|
||||
/// Add a bookmark with ⌘B at the current page and name it by typing.
|
||||
fn add(h: &mut Harness<'static, PrintCraftApp>, title: &str) {
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::B);
|
||||
h.run_steps(3);
|
||||
field(h, "Untitled"); // the inline editor is open and focused
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run_steps(1);
|
||||
field(h, "Untitled").type_text(title);
|
||||
h.run_steps(1);
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn menu(h: &mut Harness<'static, PrintCraftApp>, bookmark: &str, item: &str) {
|
||||
h.get_by_label(bookmark).click_secondary();
|
||||
h.run_steps(2);
|
||||
h.get_by_label(item).click();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rename_reorder_indent_delete_and_undo() {
|
||||
let mut h = harness();
|
||||
h.get_by_label("This document has no bookmarks.");
|
||||
add(&mut h, "Intro");
|
||||
h.state_mut().set_option("page", "3").unwrap();
|
||||
h.run_steps(3);
|
||||
add(&mut h, "Results");
|
||||
assert_eq!(outline(&h), [("Intro".into(), Some(0), 0), ("Results".into(), Some(2), 0)]);
|
||||
|
||||
menu(&mut h, "Results", "Indent");
|
||||
assert_eq!(outline(&h), [("Intro".into(), Some(0), 0), ("Results".into(), Some(2), 1)]);
|
||||
menu(&mut h, "Results", "Outdent");
|
||||
menu(&mut h, "Results", "Move up");
|
||||
assert_eq!(outline(&h)[0].0, "Results");
|
||||
|
||||
menu(&mut h, "Intro", "Rename");
|
||||
field(&h, "Intro");
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::A);
|
||||
h.run_steps(1);
|
||||
field(&h, "Intro").type_text("Introduction");
|
||||
h.key_press(Key::Enter);
|
||||
h.run_steps(3);
|
||||
assert_eq!(outline(&h)[1].0, "Introduction");
|
||||
|
||||
menu(&mut h, "Results", "Delete");
|
||||
assert_eq!(outline(&h), [("Introduction".into(), Some(0), 0)]);
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
|
||||
h.run_steps(3);
|
||||
assert_eq!(outline(&h).len(), 2, "delete is undoable");
|
||||
assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_a_rename() {
|
||||
let mut h = harness();
|
||||
add(&mut h, "Keep");
|
||||
menu(&mut h, "Keep", "Rename");
|
||||
field(&h, "Keep").type_text("zzz");
|
||||
h.key_press(Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert_eq!(outline(&h)[0].0, "Keep");
|
||||
}
|
||||
Reference in new issue
Block a user