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:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 13:10:49 -04:00
1 parent ebfe058a9c
commit 0ac2908195
18 files changed
+868 -33

No files matched your search

+2 -1
View File
@@ -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 |
+11
View File
@@ -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\""
+17
View File
@@ -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

+51
View File
@@ -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()
+22
View File
@@ -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.")
+28
View File
@@ -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")));
}
+1
View File
@@ -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"),
+49 -1
View File
@@ -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}")]
+38
View File
@@ -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"]);
}
+4
View File
@@ -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 {
+274
View File
@@ -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(())
}
+68
View File
@@ -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");
}
+1
View File
@@ -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" => {
+54
View File
@@ -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) }
}
+1
View File
@@ -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")),
+3
View File
@@ -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
View File
@@ -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);
}
}
}
+109
View File
@@ -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");
}