From 9ccf77bdf685bc3cb0f545865c9f9b3e97ab5025 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 11 Oct 2026 06:17:13 +0900 Subject: [PATCH] M4: traverse large bookmark trees lazily without dropping later items (#876) * M4: traverse large bookmark trees lazily without dropping later items The outline walk stopped silently at 100,000 bookmarks, and opening a document built the whole nested outline with the same cap. Both walks also recursed once per level and overflowed the stack on a deep chain. organize walks the outline lazily and iteratively with no item cap, and lists it a page at a time (bookmark_page). render keeps a bounded window of the first 10,000 bookmarks, nested at most 33 levels deep, and reports when more exist. automation's bookmark_list pages with offset and limit, and doc_info reports outline_more. The bookmarks panel says when bookmarks are left out. Co-Authored-By: Claude Haiku 5.5 * M4: keep the bookmarks panel note in English The note was a tl! label, and the coverage tests require every tl! label to exist in each complete catalogue. This PR adds no translations, so the note is now a plain English label in every locale. The i18n catalogues are restored to origin/main. Co-Authored-By: Claude Haiku 5.5 * Bookmarks: translate the panel's limit notice; a numeric destination names a page in the document --------- Co-authored-by: Claude Haiku 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 28 +-- crates/automation/src/lib.rs | 70 ++++-- crates/automation/src/tools.rs | 19 +- crates/automation/tests/automation.rs | 45 ++++ crates/engine/src/lib.rs | 66 +++++- crates/organize/src/lib.rs | 4 +- crates/organize/src/outline.rs | 317 +++++++++++++++++++++++--- crates/organize/src/tests.rs | 150 +++++++++++- crates/render/src/inspect.rs | 175 ++++++++++++-- crates/ui-egui/src/i18n/ar.tsv | 1 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/te.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/panels.rs | 3 + parity/acrobat-features.toml | 2 +- 25 files changed, 798 insertions(+), 95 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3d7b86294..b7df46ac1 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bd3a2ecc4e5706827859c7e55107ffe7430422a31cfae756a7410f739a18d309" +sha256 = "6f22177566d03164cba095ffce12bd84e45e585bdb6644294eb73f565bbfd8cc" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "0266a1d134096809222b7dfe40a760f308336fe11f5b51d53f6eb0bf251d130b" +sha256 = "50b667b08816c04b3d14c39df2f168784ce3b22ebd9694d08a2184b3538edc33" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cb37df8d0df51cd045816149fd07680a7014b3fba42d0ae1c6e80fab69ecbeda" +sha256 = "5afaae7ed3c8a0b032878d78fbd76a03fb1591e0d29f9ae5a6657afaa00706a6" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "90b49f520061c8174936b9e049d87b10318a1d5e1ebcc63a29f7c26a9dc56831" +sha256 = "45acfa2a5b71e7e7da7e1b975a712e610196f8eeb518c177751d378eef789b17" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "13d83f91945362a618d9ca2bfdcc7d9a11d0e2b549899aac30ff210f1f566919" +sha256 = "9d6b99b7b4ed205a39ea918e2694e06ed654d0adeff71016719ce764ca756b65" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f6bbf852d04a0f281a1e4735b619372e77963e9b30f5bc1ca040e815bd072100" +sha256 = "547480a92fff9ef5a980a00ca9ce7f35ee61484be1c42abc7c152632eb9890bc" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3d5b1e627bedbca0b34b6a7c76943ca063fe3bc4d0916e3026535716f205affa" +sha256 = "ccf859f5ebf443a92ab906b18e15daec313dbf4789824a7e75087e0e39b99ad5" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d55ea186857822a76254b2bb97ba144733f1f26764038fe4df6872c80cfc9df2" +sha256 = "4d56d65bd7863441289e77d76e8f95993be5bf0100a06501eaf3daacbc6f2074" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6164520d217da8b3ed6b15883133e6897f49de844b40f1988a80d66ddb12aa69" +sha256 = "6e401fd4a888c463335c0a89ddd403be7081a0def05c62f34fb58965b71025e0" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c8ef58ee02ce7a46452595a4a41cca91cea7dcf6aa85f7eb85947c5b67c3c0ad" +sha256 = "58964992dc3487fbe76db5e90b53f8ffd21164f738015e005020c186a356cae3" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "914b15d71f3168d2875d1583fc66c4ae1bf8aaf6a71edd0e418be188cd4c1fcf" +sha256 = "d80f6e2dabb1f657a5368447a445eeb47501e1859fe19aca80c638de56f12ed4" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c8a5b5038929f5eb3d91f479ab3b65a77e38056626fa9ef9e3e02932615d53a2" +sha256 = "72465faa8cf69c3d5c8a8b069b5757116140df69ac7ff5afb9797c1aac9ee8e5" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5d4a82edfa1721bdaf3e0f7bbd59473a6a497c7ea47104a73e2062bf03facac6" +sha256 = "dfbf320deb41dffb2f07537f850627f83c0f213d82cd20cbfd83e7abd462ca28" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "2ce6abd311d689654a7932e3ac212088e2685efd8bd4f4fa8da0a29c6c5ab3c5" +sha256 = "177e42ffd11fc0c7838ae490dbf4145b5a30b565659e5ef752be78700459b190" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 4d7858002..df118d7ae 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -77,6 +77,10 @@ fn failed(e: impl std::fmt::Display) -> ToolError { const DEFAULT_DPI: f64 = 96.0; const MAX_DPI: f64 = 600.0; +/// Bookmarks in one page of the bookmark list, by default and at most. +const BOOKMARK_PAGE: usize = 100; +const MAX_BOOKMARK_PAGE: usize = 1000; + /// Page texts of one document version: (the working bytes, one slot per page). type TextCache = (Arc>, Vec>>); @@ -242,7 +246,7 @@ impl Automation { out["labels"] = json!(self.doc(&a)?.info.pages.iter().map(|p| p.label.clone()).collect::>()); out } - "bookmark_list" => json!({ "bookmarks": bookmark_tree(&self.doc(&a)?.info.outline, &[]) }), + "bookmark_list" => self.bookmark_listing(&a)?, "bookmark_add" => { let page = self.page(&a)?; let parent = a.opt_path("parent")?.unwrap_or_default(); @@ -270,7 +274,9 @@ impl Automation { } "bookmark_from_structure" => { let mut out = self.apply(&a, Edit::BookmarksFromStructure)?; - out["bookmarks"] = json!(bookmark_tree(&self.doc(&a)?.info.outline, &[])); + let listing = self.bookmark_listing(&a)?; + out["bookmarks"] = listing["bookmarks"].clone(); + out["next"] = listing["next"].clone(); out } "doc_protect" => self.doc_protect(&a)?, @@ -632,6 +638,24 @@ impl Automation { // ---- documents --------------------------------------------------------------------------- + /// One page of a document's bookmarks: `offset` (from 0) and `limit` (1 to `MAX_BOOKMARK_PAGE`). + fn bookmark_listing(&self, a: &Args) -> Result { + let id = a.int("doc")?; + let id = DocId(u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?); + let offset = a.opt_int("offset")?.unwrap_or(0); + let offset = usize::try_from(offset).map_err(|_| ToolError::InvalidArgs("offset must be 0 or more".into()))?; + let limit = a.opt_int("limit")?.unwrap_or(BOOKMARK_PAGE as i64); + let limit = usize::try_from(limit) + .ok() + .filter(|l| (1..=MAX_BOOKMARK_PAGE).contains(l)) + .ok_or_else(|| ToolError::InvalidArgs(format!("limit must be from 1 to {MAX_BOOKMARK_PAGE}")))?; + let page = self + .session + .bookmark_page(id, offset, limit) + .ok_or_else(|| ToolError::Failed(format!("no open document with id {} (see doc_list)", id.0)))?; + Ok(json!({ "bookmarks": nest_rows(&page.rows), "next": page.next, "truncated": page.truncated })) + } + fn doc(&self, a: &Args) -> Result<&Document> { let id = a.int("doc")?; let id = u64::try_from(id).map_err(|_| ToolError::InvalidArgs("doc must be positive".into()))?; @@ -1704,17 +1728,36 @@ impl Args<'_> { } } -/// The bookmark tree as JSON, with 1-based paths and pages. -fn bookmark_tree(items: &[pdfcraft_render::OutlineItem], parent: &[usize]) -> Vec { - items - .iter() - .enumerate() - .map(|(i, o)| { - let mut path = parent.to_vec(); - path.push(i + 1); - json!({ "path": path, "title": o.title, "page": o.page.map(|p| p + 1), "open": o.open, "children": bookmark_tree(&o.children, &path) }) - }) - .collect() +/// Bookmark rows in listing order as JSON: a row holds the rows that follow it on the page at the +/// next level down in `children`, and paths and pages are 1-based. +fn nest_rows(rows: &[pdfcraft_engine::BookmarkRow]) -> Vec { + // The open rows, outermost first, with their depth and the children gathered so far. + let mut open: Vec<(usize, Value, Vec)> = Vec::new(); + let mut roots = Vec::new(); + for row in rows { + let depth = row.path.len().saturating_sub(1); + while open.last().is_some_and(|(d, _, _)| *d >= depth) { + close_row(&mut open, &mut roots); + } + let path: Vec = row.path.iter().map(|p| p.saturating_add(1)).collect(); + let fields = json!({ "path": path, "title": row.title, "page": row.page.map(|p| p.saturating_add(1)), "open": row.open }); + open.push((depth, fields, Vec::new())); + } + while !open.is_empty() { + close_row(&mut open, &mut roots); + } + roots +} + +/// Closes the innermost open row: it joins the row above it as a child, or the top level. +fn close_row(open: &mut Vec<(usize, Value, Vec)>, roots: &mut Vec) { + if let Some((_, mut fields, children)) = open.pop() { + fields["children"] = Value::Array(children); + match open.last_mut() { + Some((_, _, siblings)) => siblings.push(fields), + None => roots.push(fields), + } + } } fn one_based(pages: &[i64]) -> Result> { @@ -1784,6 +1827,7 @@ fn info(d: &Document) -> Value { "page": n + 1, "label": p.label, "width": p.width, "height": p.height, "rotation": p.rotation, })).collect::>(), "outline": outline(&i.outline), + "outline_more": i.outline_more, // Rectangles use the tools' convention (top-left of the displayed page, like // comment_list and link_list), not raw PDF user space, so they can be fed back to // geometry-taking tools (#129). diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 280af2751..af0847ebe 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -229,9 +229,20 @@ pub fn tools() -> Vec { json!({ "doc": doc(), "every": { "type": "integer", "minimum": 1 }, "before": pages("that start a new part"), "bookmarks": { "type": "boolean" }, "max_mb": { "type": "number", "exclusiveMinimum": 0 }, "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_list", + "List bookmarks", + "The bookmarks in outline order, one page at a time: each with its path (1-based, as the other bookmark tools take it), title, target page and open state, and its children on the same page. next is the offset of the following page, or null at the end. Bookmarks nested deeper than 33 levels are not listed.", + ) + .ro() + .with(schema( + json!({ + "doc": doc(), + "offset": { "type": "integer", "minimum": 0, "description": "Where the page starts, counted from 0 (the previous page's next)" }, + "limit": { "type": "integer", "minimum": 1, "maximum": 1000, "description": "Bookmarks in the page (default 100)" } + }), + &["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"], @@ -250,7 +261,7 @@ pub fn tools() -> Vec { t( "bookmark_from_structure", "New bookmarks from structure", - "Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the bookmark tree. Undoable.", + "Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the first page of the bookmark tree (see bookmark_list). Undoable.", ) .with(schema(json!({ "doc": doc() }), &["doc"])), t("page_number", "Number pages", "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix). Later pages keep their labels. Undoable.").with(schema( diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index e4abe48a8..9ffb3bbd3 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -926,6 +926,51 @@ fn bookmarks_through_tools() { assert_eq!((list[0]["title"].as_str(), list[0]["children"][0]["title"].as_str()), (Some("Body"), Some("Details"))); } +/// A flat outline of `n` top-level bookmarks, `Item 0` to `Item n-1`, on one page. +fn flat_outline_pdf(n: usize) -> Vec { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>".into(), + "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + format!("<< /Type /Outlines /First 5 0 R /Last {} 0 R /Count {n} >>", 4 + n), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]; + for k in 0..n { + let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) }; + let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 6 + k) }; + objs.push(format!("<< /Title (Item {k}) /Parent 3 0 R{prev}{next} /Dest [4 0 R /Fit] >>")); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +#[test] +fn bookmark_list_pages_reach_bookmarks_past_the_hundred_thousandth() { + let dir = workdir("bookmarks-large"); + std::fs::write(dir.join("large.pdf"), flat_outline_pdf(100_005)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "large.pdf" }))["doc"].as_u64().unwrap(); + // The default call is the first page of 100, with the offset of the next one. + let first = ok(&mut a, "bookmark_list", json!({ "doc": doc })); + assert_eq!((first["bookmarks"].as_array().unwrap().len(), first["next"].clone()), (100, json!(100))); + assert_eq!((first["bookmarks"][0]["title"].clone(), first["bookmarks"][0]["path"].clone()), (json!("Item 0"), json!([1]))); + let late = ok(&mut a, "bookmark_list", json!({ "doc": doc, "offset": 100_000, "limit": 100 })); + let titles: Vec<&str> = late["bookmarks"].as_array().unwrap().iter().filter_map(|b| b["title"].as_str()).collect(); + assert_eq!((titles.first().copied(), titles.last().copied(), late["next"].clone()), (Some("Item 100000"), Some("Item 100004"), json!(null))); + assert!(matches!(a.call("bookmark_list", &json!({ "doc": doc, "limit": 1001 })), Err(ToolError::InvalidArgs(_)))); + assert!(matches!(a.call("bookmark_list", &json!({ "doc": doc, "offset": -1 })), Err(ToolError::InvalidArgs(_)))); +} + #[test] fn bookmarks_from_structure_through_tools() { let dir = workdir("bookmarks-structure"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3b7a62887..0af719cbf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -556,6 +556,51 @@ fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: /// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all). pub type CombineSource = (String, Arc>, Option); +/// A bookmark as the bookmark list shows it. +#[derive(Clone, Debug, PartialEq)] +pub struct BookmarkRow { + /// Child indices from the top level, 0-based, as the edit functions take them. + pub path: Vec, + pub title: String, + /// Shown expanded (a positive `/Count`). + pub open: bool, + /// The 0-based page it goes to. + pub page: Option, +} + +/// One page of the bookmark list, in outline order. +#[derive(Clone, Debug, PartialEq)] +pub struct BookmarkPage { + pub rows: Vec, + /// Where the next page starts, or `None` at the end. + pub next: Option, + /// Set only when the document's structure could not be read for editing: its listing then + /// comes from the viewer's copy of the outline, which is shorter than the file's. + pub truncated: bool, +} + +/// The rows of a viewer outline, in outline order, with their paths. +fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec { + let mut rows = Vec::new(); + // One entry per open level: its items, and how many of them have been listed. + let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)]; + while let Some(&(list, next)) = levels.last() { + let Some(item) = list.get(next) else { + levels.pop(); + continue; + }; + if let Some(top) = levels.last_mut() { + top.1 += 1; + } + let path: Vec = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect(); + rows.push(BookmarkRow { path, title: item.title.clone(), open: item.open, page: item.page }); + if !item.children.is_empty() { + levels.push((&item.children, 0)); + } + } + rows +} + /// A document's working file captured for crash recovery. #[derive(Clone, Debug)] pub struct RecoverySnapshot { @@ -2946,10 +2991,29 @@ impl Session { out } + /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. + /// The document's own structure is read for it, so every bookmark can be reached by paging. + pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { + let doc = self.get(id)?; + if let Some(editor) = doc.editor.as_ref() { + let page = pdfcraft_organize::bookmark_page(&editor.cos, offset, limit); + let rows = page.bookmarks.into_iter().map(|b| BookmarkRow { path: b.path, title: b.title, open: b.open, page: b.page }).collect(); + return Some(BookmarkPage { rows, next: page.next, truncated: false }); + } + let all = outline_rows(&doc.info.outline); + let end = offset.saturating_add(limit); + let rows = all.iter().skip(offset).take(limit).cloned().collect(); + Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more }) + } + /// Top-level bookmarks as split points: (first page of each part, its bookmark's title). pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> { let Some(doc) = self.get(id) else { return Vec::new() }; - let mut out: Vec<(usize, String)> = doc.info.outline.iter().filter_map(|o| Some((o.page?, o.title.clone()))).collect(); + let top: Vec<(Option, String)> = match doc.editor.as_ref() { + Some(editor) => pdfcraft_organize::top_level_bookmarks(&editor.cos).into_iter().map(|b| (b.page, b.title)).collect(), + None => doc.info.outline.iter().map(|o| (o.page, o.title.clone())).collect(), + }; + let mut out: Vec<(usize, String)> = top.into_iter().filter_map(|(page, title)| Some((page?, title))).collect(); out.sort_by_key(|x| x.0); out.dedup_by_key(|x| x.0); out diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index ad7d98f51..3c6bf759f 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -27,8 +27,8 @@ pub use dedupe::dedupe_resources; pub use import::{Run, SplitBy, combine, combine_grouped, combine_selected, extract_pages, import_pages, page_as_form, split, split_ranges}; pub use labels::{LabelRange, LabelStyle, PageLabels, number_pages, page_label_ranges, page_labels, set_page_label_ranges}; pub use outline::{ - Bookmark, OutlineEntry, OutlineError, add_bookmark, add_bookmark_tree, bookmarks, delete_bookmark, move_bookmark, rename_bookmark, - set_bookmark_open, set_bookmark_page, + Bookmark, BookmarkPage, OutlineEntry, OutlineError, add_bookmark, add_bookmark_tree, bookmark_page, bookmarks, delete_bookmark, move_bookmark, + rename_bookmark, set_bookmark_open, set_bookmark_page, top_level_bookmarks, }; pub use view::{InitialView, displays_doc_title, initial_view, set_initial_view}; diff --git a/crates/organize/src/outline.rs b/crates/organize/src/outline.rs index 0cc81a0a0..d3ac207fc 100644 --- a/crates/organize/src/outline.rs +++ b/crates/organize/src/outline.rs @@ -7,25 +7,45 @@ //! 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. +//! Walks are cycle-safe and iterative, so a broken or deep outline is never followed forever and +//! never overflows the stack. Listing is lazy: [`bookmark_page`] visits only the bookmarks up to +//! the end of the page it returns. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use pdfcraft_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; +/// Deepest level a listing enters (the top level is 0). Bookmarks deeper than this are not +/// listed; edits still reach them by path. +const LIST_DEPTH: usize = 32; -/// A bookmark as stored in the file. +/// Deepest name-tree node read when looking up named destinations. +const NAME_TREE_DEPTH: usize = 32; + +/// Deepest chain of indirect or named destinations followed for one bookmark. +const DEST_DEPTH: u8 = 8; + +/// A bookmark in listing order, with where it sits and where it goes. #[derive(Clone, Debug, PartialEq)] pub struct Bookmark { pub obj: ObjRef, + /// Child indices from the top level (the paths the edit functions take). + pub path: Vec, pub title: String, /// Shown expanded (a positive `/Count`) when it has children. pub open: bool, - pub children: Vec, + /// The 0-based page it goes to, when that page is in this document. + pub page: Option, +} + +/// One page of the outline listing (see [`bookmark_page`]). +#[derive(Clone, Debug, PartialEq)] +pub struct BookmarkPage { + pub bookmarks: Vec, + /// The offset where the next page starts, or `None` when this page is the last. + pub next: Option, } #[derive(Debug, thiserror::Error, PartialEq)] @@ -56,7 +76,7 @@ fn children_of(doc: &Document, parent: ObjRef, seen: &mut HashSet) -> Ve 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) { + if !seen.insert(r) { break; } let item = doc.get(r); @@ -79,23 +99,229 @@ pub(crate) fn items(doc: &Document) -> Vec { out } -/// The bookmark tree. -pub fn bookmarks(doc: &Document) -> Vec { - let Some(root) = outline_root(doc) else { return Vec::new() }; - let mut seen = HashSet::from([root]); - fn build(doc: &Document, parent: ObjRef, seen: &mut HashSet) -> Vec { - children_of(doc, parent, seen) - .into_iter() - .map(|r| { - let item = doc.get(r); - let d = item.as_dict().cloned().unwrap_or_default(); - let title = d.get(b"Title").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|s| s.to_text())).unwrap_or_default(); - let open = d.int(b"Count").is_some_and(|c| c > 0); - Bookmark { obj: r, title, open, children: build(doc, r, seen) } - }) - .collect() +/// The outline in listing order: each bookmark once, siblings by `/Next`, children right after +/// their parent. An explicit stack keeps the walk iterative. A repeated or malformed item ends its +/// run of siblings there, so a cycle or a broken link stops the walk instead of looping. +struct Visit<'a> { + doc: &'a Document, + seen: HashSet, + /// One entry per open level: the next sibling to visit, and how many siblings it has visited. + levels: Vec<(Option, usize)>, + /// The deepest level the walk enters. + max_depth: usize, + /// The bookmark just visited: its children are entered by the next step. + enter: Option, +} + +impl<'a> Visit<'a> { + fn new(doc: &'a Document, max_depth: usize) -> Option { + let root = outline_root(doc)?; + let first = doc.get(root).as_dict().and_then(|d| d.reference(b"First")); + Some(Self { doc, seen: HashSet::from([root]), levels: vec![(first, 0)], max_depth, enter: None }) } - build(doc, root, &mut seen) + + /// Visit the next bookmark, or `None` when the listing is done. + fn step(&mut self) -> Option { + if let Some(first) = self.enter.take() { + self.levels.push((Some(first), 0)); + } + loop { + let depth = self.levels.len().checked_sub(1)?; + let level = self.levels.get_mut(depth)?; + let Some(r) = level.0.take() else { + self.levels.pop(); + continue; + }; + if !self.seen.insert(r) { + continue; + } + let item = self.doc.get(r); + let Some(d) = item.as_dict() else { continue }; + level.0 = d.reference(b"Next"); + level.1 = level.1.saturating_add(1); + self.enter = if depth < self.max_depth { d.reference(b"First") } else { None }; + return Some(r); + } + } + + /// Child indices from the top level to the bookmark last visited. + fn path(&self) -> Vec { + self.levels.iter().map(|(_, visited)| visited.saturating_sub(1)).collect() + } +} + +/// Page targets for a listing: the page index of each page object, and the named destinations +/// (read once, the first time one is needed). +struct Targets { + pages: HashMap, + names: Option, Object>>, +} + +impl Targets { + fn new(doc: &Document) -> Self { + let pages = walk(doc).unwrap_or_default().into_iter().enumerate().map(|(i, (r, _))| (r, i)).collect(); + Self { pages, names: None } + } + + /// The named destination `key`: its name-tree entry, else its entry in the `/Dests` dictionary. + fn named(&mut self, doc: &Document, key: &[u8]) -> Option { + let names = self.names.get_or_insert_with(|| name_tree(doc)); + names.get(key).cloned().or_else(|| dests_dict(doc, key)) + } +} + +/// Every entry of the catalog's `/Names /Dests` name tree, by name. The first entry of a name wins. +/// Iterative, and nodes deeper than `NAME_TREE_DEPTH` are skipped. +fn name_tree(doc: &Document) -> HashMap, Object> { + let mut out = HashMap::new(); + let Some(root) = doc.root() else { return out }; + let Some(names) = doc.get(root).as_dict().and_then(|c| c.get(b"Names").cloned()) else { return out }; + let names = doc.resolve(&names); + let Some(tree) = names.as_dict().and_then(|n| n.get(b"Dests").cloned()) else { return out }; + // Nodes still to read, with their depth. Kids go on the stack in reverse, so they are read in order. + let mut stack = vec![(tree, 0usize)]; + let mut seen: HashSet = HashSet::new(); + while let Some((raw, depth)) = stack.pop() { + if depth > NAME_TREE_DEPTH { + continue; + } + if let Object::Ref(r) = raw + && !seen.insert(r) + { + continue; + } + let node = doc.resolve(&raw); + let Some(d) = node.as_dict() else { continue }; + let pairs = d.get(b"Names").map(|n| doc.resolve(n)); + if let Some(pairs) = pairs.as_deref().and_then(Object::as_array) { + for pair in pairs.chunks(2) { + if let [k, v] = pair + && let Some(key) = name_key(doc, k) + { + out.entry(key).or_insert_with(|| v.clone()); + } + } + } + let kids = d.get(b"Kids").map(|k| doc.resolve(k)); + if let Some(kids) = kids.as_deref().and_then(Object::as_array) { + for kid in kids.iter().rev() { + stack.push((kid.clone(), depth + 1)); + } + } + } + out +} + +/// The bytes of a name-tree key, which is a string or a name. +fn name_key(doc: &Document, key: &Object) -> Option> { + match &*doc.resolve(key) { + Object::String(s) => Some(s.bytes.clone()), + Object::Name(n) => Some(n.clone()), + _ => None, + } +} + +/// A named destination in the catalog's PDF 1.1 `/Dests` dictionary. +fn dests_dict(doc: &Document, key: &[u8]) -> Option { + let root = doc.root()?; + let dests = doc.get(root).as_dict().and_then(|c| c.get(b"Dests").cloned())?; + let dests = doc.resolve(&dests); + dests.as_dict()?.get(key).cloned() +} + +/// The page index a destination names: an explicit `[page /XYZ …]` array, a dictionary whose `/D` +/// is one, or a named destination that resolves to one. +fn page_of_dest(doc: &Document, targets: &mut Targets, dest: &Object, depth: u8) -> Option { + if depth > DEST_DEPTH { + return None; + } + let dest = doc.resolve(dest); + match &*dest { + Object::Array(a) => match a.first()? { + Object::Ref(page) => targets.pages.get(page).copied(), + // Some producers write a page number in a remote-style destination. + Object::Int(n) => usize::try_from(*n).ok().filter(|&p| p < targets.pages.len()), + _ => None, + }, + Object::Dict(d) => page_of_dest(doc, targets, d.get(b"D")?, depth + 1), + Object::String(s) => { + let inner = targets.named(doc, &s.bytes)?; + page_of_dest(doc, targets, &inner, depth + 1) + } + Object::Name(n) => { + let inner = targets.named(doc, n)?; + page_of_dest(doc, targets, &inner, depth + 1) + } + _ => None, + } +} + +/// The page an item goes to: its `/Dest`, else the destination of its GoTo action `/A`. +fn item_page(doc: &Document, targets: &mut Targets, d: &Dict) -> Option { + if let Some(dest) = d.get(b"Dest") + && let Some(page) = page_of_dest(doc, targets, dest, 0) + { + return Some(page); + } + let action = doc.resolve(d.get(b"A")?); + let dest = action.as_dict()?.get(b"D")?; + page_of_dest(doc, targets, dest, 0) +} + +/// A bookmark's title as viewers show it: the decoded text, without NULs or surrounding space. +fn title_of(doc: &Document, d: &Dict) -> String { + let title = d.get(b"Title").map(|t| doc.resolve(t)); + let text = match title.as_deref() { + Some(Object::String(s)) => s.to_text(), + Some(Object::Name(n)) => String::from_utf8_lossy(n).into_owned(), + _ => return String::new(), + }; + text.trim_matches('\0').trim().to_string() +} + +fn describe(doc: &Document, targets: &mut Targets, obj: ObjRef, path: Vec) -> Bookmark { + let item = doc.get(obj); + let (title, open, page) = match item.as_dict() { + Some(d) => (title_of(doc, d), d.int(b"Count").is_some_and(|c| c > 0), item_page(doc, targets, d)), + None => (String::new(), false, None), + }; + Bookmark { obj, path, title, open, page } +} + +/// Lists up to `limit` bookmarks from position `offset`, entering no level deeper than `max_depth`. +fn list(doc: &Document, max_depth: usize, offset: usize, limit: usize) -> BookmarkPage { + let mut bookmarks = Vec::new(); + let Some(mut visit) = Visit::new(doc, max_depth) else { return BookmarkPage { bookmarks, next: None } }; + for _ in 0..offset { + if visit.step().is_none() { + return BookmarkPage { bookmarks, next: None }; + } + } + let mut targets = Targets::new(doc); + while bookmarks.len() < limit { + let Some(obj) = visit.step() else { break }; + bookmarks.push(describe(doc, &mut targets, obj, visit.path())); + } + let next = if bookmarks.len() == limit && visit.step().is_some() { Some(offset.saturating_add(bookmarks.len())) } else { None }; + BookmarkPage { bookmarks, next } +} + +/// Every bookmark, in listing order (bookmarks deeper than the listing depth are left out). +pub fn bookmarks(doc: &Document) -> Vec { + list(doc, LIST_DEPTH, 0, usize::MAX).bookmarks +} + +/// The top-level bookmarks, in order. +pub fn top_level_bookmarks(doc: &Document) -> Vec { + list(doc, 0, 0, usize::MAX).bookmarks +} + +/// One page of the bookmarks in listing order: up to `limit` from position `offset`, and the offset +/// where the next page starts. Pages are contiguous, so following `next` lists every bookmark once. +/// Only the bookmarks up to the end of the page are visited, so the first page does not depend on +/// how many bookmarks come after it. +pub fn bookmark_page(doc: &Document, offset: usize, limit: usize) -> BookmarkPage { + list(doc, LIST_DEPTH, offset, limit) } /// The object of the bookmark at `path`, and its parent object (the outline root for top-level). @@ -162,31 +388,46 @@ fn relink(doc: &mut Document, parent: ObjRef, kids: &[ObjRef]) -> Result<()> { Ok(()) } +/// A node whose `/Count` [`recount`] is working out. +struct Counting { + node: ObjRef, + kids: Vec, + /// The next child to count. + next: usize, + /// Items visible below the node, so far. + visible: i64, + is_root: bool, +} + /// Recompute every `/Count` (§12.3.3: open items count their visible descendants, closed items /// the negative of what opening them would show; the root counts all visible items). fn recount(doc: &mut Document) -> Result<()> { let Some(root) = outline_root(doc) else { return Ok(()) }; let mut seen = HashSet::from([root]); - // Returns the number of items visible below `node` when `node` is open. - fn visit(doc: &mut Document, node: ObjRef, is_root: bool, seen: &mut HashSet) -> Result { - let kids = children_of(doc, node, seen); - let mut visible = 0i64; - for k in &kids { - let below = visit(doc, *k, false, seen)?; - let open = doc.get(*k).as_dict().and_then(|d| d.int(b"Count")).is_some_and(|c| c > 0); - visible += 1 + if open { below } else { 0 }; + let kids = children_of(doc, root, &mut seen); + let mut stack = vec![Counting { node: root, kids, next: 0, visible: 0, is_root: true }]; + // A node is counted once its children are, so the walk keeps its own stack instead of recursing. + while let Some(top) = stack.last_mut() { + if let Some(&kid) = top.kids.get(top.next) { + top.next += 1; + let kids = children_of(doc, kid, &mut seen); + stack.push(Counting { node: kid, kids, next: 0, visible: 0, is_root: false }); + continue; } - let count = if kids.is_empty() { + let Some(done) = stack.pop() else { break }; + let count = if done.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 if done.is_root || doc.get(done.node).as_dict().and_then(|d| d.int(b"Count")).is_some_and(|c| c > 0) { + Some(done.visible) } else { - Some(-visible) + Some(-done.visible) }; - put(doc, node, b"Count", count.map(Object::Int))?; - Ok(visible) + put(doc, done.node, b"Count", count.map(Object::Int))?; + if let Some(parent) = stack.last_mut() { + let open = doc.get(done.node).as_dict().and_then(|d| d.int(b"Count")).is_some_and(|c| c > 0); + parent.visible = parent.visible.saturating_add(1 + if open { done.visible } else { 0 }); + } } - visit(doc, root, true, &mut seen)?; Ok(()) } diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index fe5e1aa17..c6d5b8394 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -951,8 +951,19 @@ fn combine_stores_an_image_once_when_its_colour_space_is_an_indirect_name() { // ---- bookmarks (M4.6) -------------------------------------------------------------------------- +/// The titles with children in brackets (`Parent[Child,Child]`), one per top-level bookmark. fn titles(b: &[crate::Bookmark]) -> Vec { - b.iter().map(|x| if x.children.is_empty() { x.title.clone() } else { format!("{}[{}]", x.title, titles(&x.children).join(",")) }).collect() + fn level(b: &[crate::Bookmark], at: &mut usize, depth: usize) -> Vec { + let mut out = Vec::new(); + while let Some(x) = b.get(*at).filter(|x| x.path.len() == depth + 1) { + *at += 1; + let kids = level(b, at, depth + 1); + out.push(if kids.is_empty() { x.title.clone() } else { format!("{}[{}]", x.title, kids.join(",")) }); + } + out + } + let mut at = 0; + level(b, &mut at, 0) } fn count_of(doc: &Document, r: ObjRef) -> Option { @@ -1013,11 +1024,12 @@ fn a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark() { 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), count_of(&d, b[0].obj), count_of(&d, b[0].children[0].obj)), (Some(6), Some(4), Some(2))); - let one = d.get(b[0].children[0].obj).as_dict().cloned().unwrap(); + // Listing order: Untitled, One, Deep, Two, Next, Existing. + assert_eq!((count_of(&d, root), count_of(&d, b[0].obj), count_of(&d, b[1].obj)), (Some(6), Some(4), Some(2))); + let one = d.get(b[1].obj).as_dict().cloned().unwrap(); assert_eq!(one.get(b"SE"), Some(&Object::Ref(se))); let pages = crate::walk(&d).unwrap(); - let next = d.get(b[0].children[1].obj).as_dict().cloned().unwrap(); + let next = d.get(b[4].obj).as_dict().cloned().unwrap(); assert_eq!(next.get(b"Dest").and_then(|x| x.as_array()).map(|a| a[0].clone()), Some(Object::Ref(pages[2].0))); assert_eq!(crate::add_bookmark_tree(&mut d, "Untitled", &[]), Err(crate::OutlineError::NoEntries)); @@ -1041,6 +1053,136 @@ fn bookmark_edits_keep_unknown_keys_and_touch_few_objects() { assert_eq!(d.modified_objects(), vec![b[0].obj.num], "a rename rewrites only that item"); } +/// A flat outline of `n` top-level bookmarks, `Item 0` to `Item n-1`, all going to one page. +fn flat_outline(n: usize) -> Vec { + let mut b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>".into(), + "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + format!("<< /Type /Outlines /First 5 0 R /Last {} 0 R /Count {n} >>", 4 + n), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]; + for k in 0..n { + let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) }; + let next = if k + 1 == n { String::new() } else { format!(" /Next {} 0 R", 6 + k) }; + b.push(format!("<< /Title (Item {k}) /Parent 3 0 R{prev}{next} /Dest [4 0 R /Fit] >>")); + } + build(&b, "/Root 1 0 R") +} + +#[test] +fn a_bookmark_past_the_hundred_thousandth_is_listed() { + let d = open(flat_outline(100_005)); + let all = crate::bookmarks(&d); + assert_eq!(all.len(), 100_005); + assert_eq!(all.last().map(|b| b.title.as_str()), Some("Item 100004")); +} + +/// A chain of `n` nested bookmarks: each is the first child of the one before it. +fn deep_outline(n: usize) -> Vec { + let mut b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>".into(), + "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]; + for k in 0..n { + let parent = if k == 0 { 3 } else { 4 + k }; + let first = if k + 1 == n { String::new() } else { format!(" /First {} 0 R /Count -1", 6 + k) }; + b.push(format!("<< /Title (Deep {k}) /Parent {parent} 0 R{first} /Dest [4 0 R /Fit] >>")); + } + build(&b, "/Root 1 0 R") +} + +#[test] +fn deep_bookmark_chains_edit_without_recursion() { + let mut d = open(deep_outline(100_000)); + // Adding at the top level recounts the whole outline, which walks the entire chain. + crate::add_bookmark(&mut d, &[], 1, "Extra", 0).unwrap(); + // Listing enters levels 0 to 32 of the chain; the rest is reached by path only. + let listed = crate::bookmarks(&d); + assert_eq!(listed.len(), 34); + assert_eq!(listed.last().map(|b| (b.title.as_str(), b.path.clone())), Some(("Extra", vec![1]))); + assert_eq!(listed[32].path.len(), 33); + assert_eq!(crate::bookmark_page(&d, 33, 10).bookmarks.len(), 1); +} + +/// A one-page document whose outline is the given bookmark bodies, as objects 5, 6, … in order. +fn outline_with(items: &[&str]) -> Document { + let mut b: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>".into(), + "<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + format!("<< /Type /Outlines /First 5 0 R /Count {} >>", items.len()), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]; + b.extend(items.iter().map(|s| s.to_string())); + open(build(&b, "/Root 1 0 R")) +} + +#[test] +fn hostile_outlines_end_their_runs_and_never_loop() { + let listed = |items: &[&str]| titles(&crate::bookmarks(&outline_with(items))); + // A bookmark that is its own next sibling, or its own first child. + assert_eq!(listed(&["<< /Title (Self) /Parent 3 0 R /Next 5 0 R >>"]), ["Self"]); + assert_eq!(listed(&["<< /Title (Self) /Parent 3 0 R /First 5 0 R >>"]), ["Self"]); + // Two siblings that point at each other: the run ends where it would repeat. + assert_eq!(listed(&["<< /Title (A) /Parent 3 0 R /Next 6 0 R >>", "<< /Title (B) /Parent 3 0 R /Next 5 0 R >>"]), ["A", "B"]); + // A child that leads back to its ancestor. + assert_eq!(listed(&["<< /Title (A) /Parent 3 0 R /First 6 0 R >>", "<< /Title (B) /Parent 5 0 R /First 5 0 R >>"]), ["A[B]"]); + // A broken `/Next` (a missing object, then a number) and a missing first bookmark. + assert_eq!(listed(&["<< /Title (A) /Parent 3 0 R /Next 99 0 R >>"]), ["A"]); + assert_eq!(listed(&["<< /Title (A) /Parent 3 0 R /Next 6 0 R >>", "42"]), ["A"]); + assert!(listed(&[]).is_empty()); +} + +#[test] +fn bookmark_pages_list_every_bookmark_once() { + let d = open(flat_outline(250)); + let mut seen = Vec::new(); + let mut offset = 0; + while let Some(next) = { + let page = crate::bookmark_page(&d, offset, 100); + assert!(page.bookmarks.len() <= 100); + seen.extend(page.bookmarks.iter().map(|b| b.title.clone())); + page.next + } { + assert_eq!(next, seen.len(), "a page ends where the next one starts"); + offset = next; + } + assert_eq!(seen.len(), 250); + assert_eq!(seen.last().map(String::as_str), Some("Item 249")); + + let big = open(flat_outline(100_005)); + let first = crate::bookmark_page(&big, 0, 100); + assert_eq!((first.bookmarks.len(), first.next), (100, Some(100))); + let late = crate::bookmark_page(&big, 100_000, 100); + assert_eq!( + late.bookmarks.iter().map(|b| b.title.as_str()).collect::>(), + ["Item 100000", "Item 100001", "Item 100002", "Item 100003", "Item 100004"] + ); + assert_eq!((late.bookmarks[4].path.clone(), late.next), (vec![100_004], None)); +} + +#[test] +fn bookmarks_name_their_pages_through_every_kind_of_destination() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R /Names << /Dests 9 0 R >> >>".into(), + "<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Outlines /First 6 0 R /Last 11 0 R /Count 6 >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Title (Named) /Parent 3 0 R /Next 7 0 R /Dest (intro) >>".into(), + "<< /Title (GoTo) /Parent 3 0 R /Prev 6 0 R /Next 8 0 R /A << /S /GoTo /D [5 0 R /Fit] >> >>".into(), + "<< /Title (Appendix) /Parent 3 0 R /Prev 7 0 R /Next 10 0 R /Dest /appendix >>".into(), + "<< /Names [(appendix) 12 0 R (intro) [4 0 R /Fit]] >>".into(), + "<< /Title /Parent 3 0 R /Prev 8 0 R /Next 11 0 R /Dest [1 /Fit] >>".into(), + "<< /Title ( Padded ) /Parent 3 0 R /Prev 10 0 R /Dest [2 0 R /Fit] >>".into(), + "<< /D [5 0 R /Fit] >>".into(), + ]; + let d = open(build(&objs, "/Root 1 0 R")); + let listed: Vec = crate::bookmarks(&d).iter().map(|b| format!("{}@{:?}", b.title, b.page)).collect(); + assert_eq!(listed, ["Named@Some(0)", "GoTo@Some(1)", "Appendix@Some(1)", "Hi@Some(1)", "Padded@None"]); +} + // ---- page labels (M4.4) ------------------------------------------------------------------------ #[test] diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 2e066b81c..ab20f5608 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -30,6 +30,13 @@ const MAX_LAYER_STATE: usize = 1024; /// At most this many layers are read from all of `/RBGroups` together. const MAX_LAYER_GROUP_ENTRIES: usize = 4096; +/// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document +/// reads only this window; the rest are listed by page through `pdfcraft_organize`. +const OUTLINE_WINDOW: usize = 10_000; + +/// Deepest level `DocInfo::outline` nests (the top level is 0); deeper bookmarks are left out. +const OUTLINE_DEPTH: usize = 32; + fn load_options(password: Option<&str>) -> LoadOptions { LoadOptions { password: password.map(str::to_owned), max_decompressed_size: Some(LOAD_STREAM_LIMIT), ..LoadOptions::default() } } @@ -51,6 +58,8 @@ pub struct DocInfo { pub xfa: Option, pub pages: Vec, pub outline: Vec, + /// The outline has bookmarks that `outline` leaves out (see `OUTLINE_WINDOW`, `OUTLINE_DEPTH`). + pub outline_more: bool, pub annotations: Vec, pub fields: Vec, pub links: Vec, @@ -475,8 +484,7 @@ impl<'a> Inspector<'a> { Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")), } if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { - let mut seen = HashSet::new(); - info.outline = self.outline_siblings(first, &mut seen, 0); + (info.outline, info.outline_more) = self.outline_window(first); } self.annotations(info); let mut field_seen = HashSet::new(); @@ -668,34 +676,49 @@ impl<'a> Inspector<'a> { // ── outline ───────────────────────────────────────────────────────────────────────────── - fn outline_siblings(&self, first: &'a Object, seen: &mut HashSet, depth: u32) -> Vec { - let mut items = Vec::new(); - let mut cur = Some(first); - while let Some(o) = cur { + /// The window `DocInfo::outline` keeps: the first `OUTLINE_WINDOW` bookmarks in outline order, + /// nested no deeper than `OUTLINE_DEPTH`, and whether any bookmark was left out. Iterative, and + /// each bookmark is read once, so the work and memory stay within the window. + fn outline_window(&self, first: &'a Object) -> (Vec, bool) { + let mut seen = HashSet::new(); + // The listed bookmarks in outline order, with their depth. + let mut listed: Vec<(usize, OutlineItem)> = Vec::new(); + // One entry per open level: its next sibling, or `None` once that run of siblings has ended. + let mut levels: Vec> = vec![Some(first)]; + let mut more = false; + while let Some(depth) = levels.len().checked_sub(1) { + let Some(slot) = levels.get_mut(depth) else { break }; + let Some(o) = slot.take() else { + levels.pop(); + continue; + }; if let Object::Reference(id) = o && !seen.insert(*id) { - break; // cycle + continue; // cycle: this run of siblings ends here } - let Some(d) = self.dict(o) else { break }; + let Some(d) = self.dict(o) else { continue }; + if listed.len() >= OUTLINE_WINDOW { + more = true; + break; + } + *slot = d.get(b"Next").ok(); let dest = d .get(b"Dest") .ok() .and_then(|dest| self.dest(dest, 0)) .or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0))); let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v)); - let children = match (d.get(b"First").ok(), depth < 32) { - (Some(f), true) => self.outline_siblings(f, seen, depth + 1), - _ => Vec::new(), - }; - let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0); - items.push(OutlineItem { title: self.text(d, b"Title").unwrap_or_default(), page, view, children, open }); - cur = d.get(b"Next").ok(); - if items.len() > 100_000 { - break; + match d.get(b"First").ok() { + Some(child) if depth < OUTLINE_DEPTH => levels.push(Some(child)), + Some(_) => more = true, + None => {} } + let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0); + let title = self.text(d, b"Title").unwrap_or_default(); + listed.push((depth, OutlineItem { title, page, view, children: Vec::new(), open })); } - items + (nest_outline(listed), more) } // ── page labels (ISO 32000-2 §12.4.2) ─────────────────────────────────────────────────── @@ -1203,6 +1226,33 @@ fn text_string(bytes: &[u8]) -> String { pdfcraft_cos::PdfString::literal(bytes).to_text() } +/// Nests a pre-order list of `(depth, item)` into the outline tree. +fn nest_outline(listed: Vec<(usize, OutlineItem)>) -> Vec { + let mut roots = Vec::new(); + // The open ancestors, outermost first; each collects its children until it closes. + let mut open: Vec<(usize, OutlineItem)> = Vec::new(); + for (depth, item) in listed { + while open.last().is_some_and(|(d, _)| *d >= depth) { + close_outline(&mut open, &mut roots); + } + open.push((depth, item)); + } + while !open.is_empty() { + close_outline(&mut open, &mut roots); + } + roots +} + +/// Closes the innermost open bookmark: it joins its parent's children, or the top level. +fn close_outline(open: &mut Vec<(usize, OutlineItem)>, roots: &mut Vec) { + if let Some((_, item)) = open.pop() { + match open.last_mut() { + Some((_, parent)) => parent.children.push(item), + None => roots.push(item), + } + } +} + fn rect4(o: &Object) -> [f32; 4] { match o { Object::Array(a) if a.len() == 4 => { @@ -1980,6 +2030,95 @@ trailer << /Root 1 0 R >> assert_eq!(titles, ["第一章", "目录", "A\u{FFFD}B", "概述", "Café", "A"]); } + /// A file whose objects are `bodies` in order (object 1 first), with a cross-reference table. + fn pdf_of(bodies: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + out + } + + /// A one-page file whose outline is `items` (objects 5, 6, … in order). + fn outline_file(items: &[String]) -> Vec { + let mut bodies = vec![ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), + "<< /Type /Page /Parent 2 0 R >>".to_string(), + format!("<< /Type /Outlines /First 5 0 R /Count {} >>", items.len()), + ]; + bodies.extend(items.iter().cloned()); + pdf_of(&bodies) + } + + /// The titles of `items`, with children in brackets (`Parent[Child]`). + fn shape(items: &[OutlineItem]) -> Vec { + items.iter().map(|o| if o.children.is_empty() { o.title.clone() } else { format!("{}[{}]", o.title, shape(&o.children).join(",")) }).collect() + } + + #[test] + fn outline_window_keeps_the_first_bookmarks_and_says_when_more_follow() { + const COUNT: usize = OUTLINE_WINDOW + 5; + let items: Vec = (0..COUNT) + .map(|k| { + let prev = if k == 0 { String::new() } else { format!(" /Prev {} 0 R", 4 + k) }; + let next = if k + 1 == COUNT { String::new() } else { format!(" /Next {} 0 R", 6 + k) }; + format!("<< /Title (Item {k}) /Parent 4 0 R{prev}{next} /Dest [3 0 R /Fit] >>") + }) + .collect(); + let info = inspect(Arc::new(outline_file(&items)), None).expect("opens"); + assert_eq!(info.outline.len(), OUTLINE_WINDOW); + assert_eq!(info.outline.last().map(|o| o.title.as_str()), Some("Item 9999")); + assert!(info.outline_more); + + let one = inspect(Arc::new(outline_file(&["<< /Title (Only) /Parent 4 0 R /Dest [3 0 R /Fit] >>".into()])), None).expect("opens"); + assert_eq!(shape(&one.outline), ["Only"]); + assert!(!one.outline_more); + } + + #[test] + fn outline_window_nests_no_deeper_than_its_depth_limit() { + // A chain of 40 bookmarks, each the first child of the one before. + let items: Vec = (0..40) + .map(|k| { + let parent = if k == 0 { 4 } else { 4 + k }; + let first = if k + 1 == 40 { String::new() } else { format!(" /First {} 0 R", 6 + k) }; + format!("<< /Title (Deep {k}) /Parent {parent} 0 R{first} /Dest [3 0 R /Fit] >>") + }) + .collect(); + let info = inspect(Arc::new(outline_file(&items)), None).expect("opens"); + let mut levels = 0; + let mut level = info.outline.first(); + while let Some(item) = level { + levels += 1; + level = item.children.first(); + } + assert_eq!(levels, OUTLINE_DEPTH + 1); + assert!(info.outline_more); + } + + #[test] + fn outline_window_ends_cycles_and_broken_links_without_looping() { + let listed = |items: &[&str]| { + let items: Vec = items.iter().map(|s| s.to_string()).collect(); + shape(&inspect(Arc::new(outline_file(&items)), None).expect("opens").outline) + }; + assert_eq!(listed(&["<< /Title (Self) /Parent 4 0 R /Next 5 0 R >>"]), ["Self"]); + assert_eq!(listed(&["<< /Title (Self) /Parent 4 0 R /First 5 0 R >>"]), ["Self"]); + assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 6 0 R >>", "<< /Title (B) /Parent 4 0 R /Next 5 0 R >>"]), ["A", "B"]); + assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /First 6 0 R >>", "<< /Title (B) /Parent 5 0 R /First 5 0 R >>"]), ["A[B]"]); + assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 99 0 R >>"]), ["A"]); + assert_eq!(listed(&["<< /Title (A) /Parent 4 0 R /Next 6 0 R >>", "42"]), ["A"]); + } + #[test] fn destination_points_map_to_the_displayed_page() { // A crop box away from the origin: x 100..400, y 50..450. diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index eaa4d24b2..e0b878c9e 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1098,6 +1098,7 @@ Dates التواريخ Text in images isn't found; recognise text (OCR) first. النص داخل الصور لا يُعثر عليه؛ استخدم التعرف على النص (OCR) أولًا. No matches. لا توجد تطابقات. + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. بعض الإشارات المرجعية غير معروضة هنا: تعرض اللوحة عددًا محدودًا منها، وبعمق تداخل محدود. {n} match(es) marked for redaction. عُلِّم {n} تطابق للحجب. Multiple words or phrases كلمات أو عبارات متعددة One word or phrase per line كلمة أو عبارة واحدة في كل سطر diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index a91eb8f81..47eba6a4e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -790,6 +790,7 @@ Dates Дати Text in images isn't found; recognise text (OCR) first. Текстът в изображения не се открива; първо разпознайте текста (OCR). No matches. Няма съвпадения. + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Някои отметки не са показани тук: панелът показва ограничен брой, с ограничена дълбочина на влагане. {n} match(es) marked for redaction. {n} съвпадение(я) маркирани за заличаване. Multiple words or phrases Няколко думи или фрази One word or phrase per line По една дума или фраза на ред diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0f539b14b..7955431d6 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1079,6 +1079,7 @@ Dates Datumsangaben Text in images isn't found; recognise text (OCR) first. Text in Bildern wird nicht gefunden; führe zuerst Text erkennen (OCR) aus. No matches. Keine Treffer. + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Einige Lesezeichen werden hier nicht angezeigt: Das Fenster listet nur eine begrenzte Anzahl bis zu einer begrenzten Tiefe auf. {n} match(es) marked for redaction. {n} Treffer zum Schwärzen markiert. Multiple words or phrases Mehrere Wörter oder Ausdrücke One word or phrase per line Ein Wort oder Ausdruck pro Zeile diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 94e58c9cd..270434096 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1092,6 +1092,7 @@ Dates Fechas Text in images isn't found; recognise text (OCR) first. El texto de las imágenes no se encuentra; reconoce antes el texto (OCR). No matches. Sin coincidencias. + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Algunos marcadores no se muestran aquí: el panel muestra un número limitado, con una profundidad de anidación limitada. {n} match(es) marked for redaction. {n} coincidencia(s) marcada(s) para censurar. Multiple words or phrases Varias palabras o frases One word or phrase per line Una palabra o frase por línea diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6fe6ed05a..abf2163fd 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1078,6 +1078,7 @@ Dates Dates Text in images isn't found; recognise text (OCR) first. Le texte dans les images n'est pas trouvé ; reconnaissez d'abord le texte (OCR). No matches. Aucune correspondance. + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Certains signets ne sont pas affichés ici : le panneau en liste un nombre limité, sur une profondeur limitée. {n} match(es) marked for redaction. {n} correspondance(s) marquée(s) pour biffure. Multiple words or phrases Plusieurs mots ou expressions One word or phrase per line Un mot ou une expression par ligne diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 084c352b1..0724c24db 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1068,6 +1068,7 @@ Dates Dátumok Text in images isn't found; recognise text (OCR) first. A képekben lévő szöveg nem található meg; előbb futtass szövegfelismerést (OCR). No matches. Nincs találat. + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Néhány könyvjelző itt nem látható: a panel korlátozott számút és korlátozott mélységig sorol fel. {n} match(es) marked for redaction. {n} találat megjelölve kitakarásra. Multiple words or phrases Több szó vagy kifejezés One word or phrase per line Soronként egy szó vagy kifejezés diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 76c6db343..d3a81d4df 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1094,6 +1094,7 @@ Dates Date Text in images isn't found; recognise text (OCR) first. Il testo nelle immagini non viene trovato; esegui prima il riconoscimento del testo (OCR). No matches. Nessuna corrispondenza. + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Alcuni segnalibri non sono mostrati qui: il pannello ne elenca un numero limitato, fino a una profondità limitata. {n} match(es) marked for redaction. Corrispondenze contrassegnate per l'oscuramento: {n}. Multiple words or phrases Più parole o frasi One word or phrase per line Una parola o frase per riga diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6f57bfe43..a5b1bf4d9 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1098,6 +1098,7 @@ Dates 日付 Text in images isn't found; recognise text (OCR) first. 画像内のテキストは検索できません。先に OCR でテキストを認識してください。 No matches. 一致する項目はありません。 + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. 一部のしおりはここに表示されません。パネルに表示できる数と階層の深さには上限があります。 {n} match(es) marked for redaction. {n} 件の一致項目に墨消しマークを付けました。 Multiple words or phrases 複数の単語または語句 One word or phrase per line 1 行に 1 つの単語または語句 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 73994df20..f4ad61fcb 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -734,6 +734,7 @@ Dates Datas Text in images isn't found; recognise text (OCR) first. Texto em imagens não é encontrado; reconheça o texto (OCR) primeiro. No matches. Nenhuma correspondência. + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Alguns marcadores não são mostrados aqui: o painel lista um número limitado, com profundidade limitada. {n} match(es) marked for redaction. {n} correspondência(s) marcada(s) para ocultação. Multiple words or phrases Várias palavras ou frases One word or phrase per line Uma palavra ou frase por linha diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 39352d83b..28d80d71a 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1099,6 +1099,7 @@ Dates Даты Text in images isn't found; recognise text (OCR) first. Текст в изображениях не находится; сначала распознайте текст (OCR). No matches. Совпадений нет. + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Некоторые закладки здесь не показаны: панель показывает ограниченное число закладок с ограниченной глубиной вложенности. {n} match(es) marked for redaction. Отмечено для сокрытия: {n} совпад. Multiple words or phrases Несколько слов или фраз One word or phrase per line По одному слову или фразе в строке diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 214c86828..c33fbb6d2 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1073,6 +1073,7 @@ comment menu Edit సవరించు Dates తేదీలు Text in images isn't found; recognise text (OCR) first. చిత్రాలలోని టెక్స్ట్ కనుగొనబడదు; ముందుగా టెక్స్ట్‌ను గుర్తించండి (OCR). No matches. సరిపోలికలు ఏవీ లేవు. + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. కొన్ని బుక్‌మార్క్‌లు ఇక్కడ చూపబడవు: ప్యానెల్ పరిమిత సంఖ్యలో, పరిమిత లోతు వరకు మాత్రమే జాబితా చేస్తుంది. {n} match(es) marked for redaction. రెడాక్షన్ కోసం గుర్తించిన సరిపోలికలు: {n}. Multiple words or phrases బహుళ పదాలు లేదా పదబంధాలు One word or phrase per line ఒక్కో పంక్తికి ఒక పదం లేదా పదబంధం diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d2e04adf2..6333695e5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1096,6 +1096,7 @@ Dates Дати Text in images isn't found; recognise text (OCR) first. Текст у зображеннях не знаходиться; спочатку розпізнайте текст (OCR). No matches. Збігів немає. + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. Деякі закладки тут не показано: панель показує обмежену кількість закладок з обмеженою глибиною вкладення. {n} match(es) marked for redaction. Збігів, позначених для вилучення: {n}. Multiple words or phrases Кілька слів або фраз One word or phrase per line По одному слову або фразі в рядку diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1e6aa98f1..050d37493 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -797,6 +797,7 @@ Dates 日期 Text in images isn't found; recognise text (OCR) first. 图像中的文本无法找到;请先识别文本(OCR)。 No matches. 无匹配。 + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. 此处未显示部分书签:面板列出的书签数量和嵌套深度有限。 {n} match(es) marked for redaction. 已标记 {n} 个匹配以待密文。 Multiple words or phrases 多个单词或短语 One word or phrase per line 每行一个单词或短语 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c31592bd1..39248b7fa 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -746,6 +746,7 @@ Dates 日期 Text in images isn't found; recognise text (OCR) first. 圖片中的文字無法找到;請先識別文字(OCR)。 No matches. 無匹配。 + Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth. 此處未顯示部分書籤:面板列出的書籤數量和巢狀深度有限。 {n} match(es) marked for redaction. 已標記 {n} 個匹配以待遮蔽內容。 Multiple words or phrases 多個單字或片語 One word or phrase per line 每行一個單字或片語 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 64dd98a52..a76895dfe 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -553,6 +553,9 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { for (i, item) in info.outline.iter().enumerate() { outline_item(ui, &t, info, item, &[i], info.outline.len(), &mut ctx); } + if info.outline_more { + ui.label(tl!("Some bookmarks are not shown here: the panel lists a limited number, nested a limited depth.")); + } } RightPanel::Pages => pages(ui, &t, info, view, modal, bm_editable, &mut nav), RightPanel::Fields => field_properties = fields(ui, &t, info, &doc.form, preparing, &mut view.prepare, &mut nav, &mut panel_edit), diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 7ae517357..2b7cff8dd 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1251,7 +1251,7 @@ status = "shipped" commands = [] tools = ["doc_info"] evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/view.rs::bookmark_destinations_scroll_to_their_position"] -notes = "A bookmark goes to its destination's position and zoom, not just its page" +notes = "A bookmark goes to its destination's position and zoom, not just its page. The panel shows the first 10,000 bookmarks, nested 33 levels deep at most, and says so; bookmark_list pages through every bookmark in the file (up to 33 levels deep)." [[feature]] id = "view.highlight-current-bookmark"