M3: a file named with open parameters opens at their destination (#3425) (#5049)

This commit is contained in:
Hazel Granados
2026-11-10 14:23:54 +07:00
committed by GitHub
parent dec9f3600b
commit 45d51d8c46
20 changed files with 610 additions and 40 deletions
+1
View File
@@ -295,6 +295,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged.
- **2026-10-27 (M10, `ocr.ocr-web`, rebase fix):** The web build loads OCR models only when their SHA-256 matches `ATTRIBUTION.toml`. With ONNX loading now on, rten's parser spins forever on some bytes that aren't a model (a corrupt or wrong download), so a bad file now fails at once instead of hanging a worker.
- **2026-10-27 (M3, #3425):** A file named with open parameters, as help systems start a PDF reader (`manual.pdf#nameddest=Intro`, `#page=12` or `#Intro`), opens at that destination instead of failing to read `manual.pdf#…`: at launch, when another launch hands it over, and in its tab when it is already open. Other parameters are ignored. The app and `link_open` now also apply a GoToR link's named destination, looked up in the target's /Names /Dests or catalog /Dests, instead of warning that it wasn't applied; the app looks names up off the UI thread. `doc_open` reports the destination of a path with open parameters. Estimates unchanged.
- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence.
- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged.
+33 -2
View File
@@ -152,9 +152,20 @@ pub fn parse_request(line: &str) -> Option<(Vec<String>, bool)> {
}
/// File arguments made absolute against this launch's working folder, which the running app
/// doesn't share. A path that can't be made absolute is sent as it is.
/// doesn't share. A path that can't be made absolute is sent as it is. Of a file named with open
/// parameters (`manual.pdf#nameddest=Intro`, #3425), only the file is made absolute and the
/// parameters are sent as given: as part of the path, `//` and `/./` in a destination name were
/// folded, and on Windows `/` became `\`, `..` took the file name with it and a trailing dot went.
pub fn absolute(paths: &[String]) -> Vec<String> {
paths.iter().map(|p| std::path::absolute(p).map_or_else(|_| p.clone(), |a| a.to_string_lossy().into_owned())).collect()
let made = |p: &str| std::path::absolute(p).map_or_else(|_| p.to_string(), |a| a.to_string_lossy().into_owned());
paths
.iter()
.map(|p| match pdfcraft_engine::open_parameters::split(p, |f| std::path::Path::new(f).is_file()) {
// `file` is what comes before one of `p`'s `#`, so the rest starts at that `#`.
Some((file, _)) => format!("{}{}", made(&file), p.get(file.len()..).unwrap_or_default()),
None => made(p),
})
.collect()
}
/// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false
@@ -416,6 +427,26 @@ mod tests {
assert_eq!(absolute(std::slice::from_ref(&already)), vec![already]);
}
/// #3425: the open parameters after a file reach the running app as they were given. Made
/// absolute with the file, `a//b/./c` became `a/b/c` (`a\b\c` on Windows), and on Windows
/// `x/../y` lost the file name and `Intro.` its dot.
#[test]
fn only_the_file_before_open_parameters_is_made_absolute() {
let dir = std::env::temp_dir().join(format!("pdfcraft-instance-{}-open-parameters", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (manual, notes) = (dir.join("manual.pdf"), dir.join("notes#2.pdf"));
std::fs::write(&manual, b"%PDF-1.7").unwrap();
std::fs::write(&notes, b"%PDF-1.7").unwrap();
let made = |p: &std::path::Path| std::path::absolute(p).unwrap().to_string_lossy().into_owned();
for parameters in ["#nameddest=a//b/./c", "#nameddest=x/../y", "#Intro.", "#page=2"] {
let given = format!("{}{parameters}", manual.to_string_lossy());
assert_eq!(absolute(&[given]), vec![format!("{}{parameters}", made(&manual))], "{parameters}");
}
// A file with `#` in its name is made absolute whole, as before.
assert_eq!(absolute(&[notes.to_string_lossy().into_owned()]), vec![made(&notes)]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn each_windows_session_gets_its_own_socket_name() {
// The session id decides: #3338, a launch from Task Scheduler (no SESSIONNAME) in the
+17 -2
View File
@@ -104,12 +104,27 @@ impl Automation {
}
pub(crate) fn doc_open(&mut self, a: &Args) -> Result<Value> {
let path = self.resolve(a.str("path")?, false)?;
let requested = a.str("path")?;
// `manual.pdf#nameddest=Intro`, as a help system starts a PDF reader (#3425): the file under
// --root, and where it opens. A path that names a file is never split.
let parameters = pdfcraft_engine::open_parameters::split(requested, |p| self.resolve(p, false).is_ok_and(|p| p.is_file()));
let (requested, destination) = match &parameters {
Some((file, destination)) => (file.as_str(), Some(destination.clone())),
None => (requested, None),
};
let path = self.resolve(requested, false)?;
let bytes = read_input(&path)?;
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let id = self.session.open(name, Some(path.to_string_lossy().into_owned()), Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
let doc = self.session.get(id).ok_or_else(|| failed("the document vanished"))?;
Ok(summary(doc))
let mut value = summary(doc);
if let Some(destination) = destination {
let (applied, destination, warning) = crate::links::destination_report(doc, destination);
value["destination_applied"] = json!(applied);
value["destination"] = destination;
value["warning"] = json!(warning);
}
Ok(value)
}
pub(crate) fn doc_close(&mut self, a: &Args) -> Result<Value> {
+21 -14
View File
@@ -11,6 +11,26 @@ use std::path::Path;
use crate::comments::parse_color;
use crate::{Args, Automation, Result, ToolError, failed};
/// Where `destination` puts `doc`, a document just opened from a link or with open parameters:
/// whether it applies, the destination as reported, and a warning when it doesn't. A named
/// destination is looked up in the document, as the app does.
pub(crate) fn destination_report(doc: &pdfcraft_engine::Document, destination: RemoteDestination) -> (bool, Value, Option<&'static str>) {
let (name, page, view) = match destination {
RemoteDestination::Default => return (true, json!({ "kind": "default" }), None),
RemoteDestination::Page { page, view } => (None, page, view),
RemoteDestination::Named(name) => match pdfcraft_render::named_destination(doc.bytes.clone(), doc.password.as_deref(), &name) {
Some((page, view)) => (Some(name), page, view),
None => return (false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied.")),
},
};
let mut value = json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) });
if let Some(name) = name {
value["kind"] = json!("named");
value["name"] = json!(name);
}
if page < doc.info.pages.len() { (true, value, None) } else { (false, value, Some("The PDF opened, but the page destination is out of range.")) }
}
fn bad(m: impl Into<String>) -> ToolError {
ToolError::InvalidArgs(m.into())
}
@@ -222,20 +242,7 @@ impl Automation {
let id =
self.session.open(name, Some(path.to_string_lossy().into_owned()), std::sync::Arc::new(bytes), a.opt_str("password")?).map_err(failed)?;
let opened = self.session.get(id).ok_or_else(|| failed("the linked document vanished"))?;
let (destination_applied, destination_value, warning) = match destination {
RemoteDestination::Default => (true, json!({ "kind": "default" }), None),
RemoteDestination::Named(name) => {
(false, json!({ "kind": "named", "name": name }), Some("The PDF opened, but the named destination was not applied."))
}
RemoteDestination::Page { page, view } if page < opened.info.pages.len() => {
(true, json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }), None)
}
RemoteDestination::Page { page, view } => (
false,
json!({ "kind": "page", "page": page.saturating_add(1), "view": dest_view_value(view) }),
Some("The PDF opened, but the link's page destination is out of range."),
),
};
let (destination_applied, destination_value, warning) = destination_report(opened, destination);
let mut display_path = path.to_string_lossy().into_owned();
if let Some(root) = &self.root
&& let Ok(relative) = path.strip_prefix(root)
+2 -2
View File
@@ -183,7 +183,7 @@ pub fn tools() -> Vec<ToolDef> {
)),
t("attachment_edit", "Edit an attachment description", "Change the description of a document attachment by its exact key from doc_info, or by a unique displayed name. An empty description clears it. The file and other attachment data are preserved. Undoable.")
.with(schema(json!({"doc": doc(), "key": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "description"])),
t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited.")
t("doc_open", "Open a PDF", "Open a PDF file and return its document id, page count and whether it can be edited. A path that ends in open parameters, as help systems pass them (`manual.pdf#page=12`, `#nameddest=Intro` or `#Intro`), opens the file and reports its destination as link_open does; a path that names a file opens as it is.")
.cmd("file.open")
.with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])),
t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])),
@@ -855,7 +855,7 @@ pub fn tools() -> Vec<ToolDef> {
t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom). A relative url also has resolved_url, the address resolved against the document's base URI (catalog /URI /Base); a link active only in its /QuadPoints has active_areas, one rect per quadrilateral. With text_links: true, also the web and email addresses written as plain page text with no link over them, which the viewer opens on a click: page, rects (one per line), url, and blocked (why it won't open) when the link check refuses it. Only http, https and mailto addresses are found. At most 100 pages are read per call, from text_links_from (default 1); text_links_next_page then gives the page to continue from.")
.ro()
.with(schema(json!({ "doc": doc(), "text_links": { "type": "boolean" }, "text_links_from": { "type": "integer", "minimum": 1 } }), &["doc"])),
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. A named destination is looked up in the target, as the app does; one the target doesn't define is reported with a warning. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }),
&["doc", "page", "index"],
+58 -1
View File
@@ -9,7 +9,16 @@ use serde_json::{Value, json};
/// A PDF with `n` 200×300 pt pages reading "Page 1", "Page 2", …
fn fixture(n: usize) -> Vec<u8> {
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
fixture_with(n, "")
}
/// Named destinations for [`fixture_with`]: `chapter` (page 2) in the catalog's /Dests and
/// `Load Flow` (page 3) in the /Names /Dests tree.
const NAMED_DESTS: &str = "/Dests << /chapter [6 0 R /Fit] >> /Names << /Dests << /Names [(Load Flow) [8 0 R /XYZ 0 300 0]] >> >>";
/// [`fixture`] with `catalog` added to the catalog dictionary. Page `i` (0-based) is object `4 + 2i`.
fn fixture_with(n: usize, catalog: &str) -> Vec<u8> {
let mut objs: Vec<Vec<u8>> = vec![format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>").into_bytes()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")).into_bytes());
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
@@ -7398,6 +7407,43 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() {
}
}
#[test]
fn doc_open_reads_open_parameters_after_the_file_name() {
// #3425: help systems start a PDF reader with `manual.pdf#nameddest=…` or `#page=…`.
let (_base, root) = sandbox("open-parameters");
std::fs::write(root.join("manual.pdf"), fixture_with(3, NAMED_DESTS)).unwrap();
std::fs::write(root.join("odd#page=2.pdf"), fixture(1)).unwrap();
let mut a = auto(&root);
for (path, kind, page) in [("manual.pdf#page=3", "page", 3), ("manual.pdf#nameddest=Load Flow", "named", 3), ("manual.pdf#chapter", "named", 2)] {
let opened = ok(&mut a, "doc_open", json!({ "path": path }));
assert_eq!((opened["name"].as_str(), opened["pages"].as_u64()), (Some("manual.pdf"), Some(3)), "{path}");
assert_eq!(opened["destination_applied"], true, "{path}: {opened}");
assert_eq!((opened["destination"]["kind"].as_str(), opened["destination"]["page"].as_u64()), (Some(kind), Some(page)), "{path}");
}
for (path, warning) in
[("manual.pdf#nameddest=Nowhere", "named destination"), ("manual.pdf#page=9", "out of range"), ("manual.pdf#page=99999", "out of range")]
{
let opened = ok(&mut a, "doc_open", json!({ "path": path }));
assert_eq!(opened["destination_applied"], false, "{path}");
assert!(opened["warning"].as_str().is_some_and(|w| w.contains(warning)), "{path}: {opened}");
}
// A parameter that can't be applied isn't a failure: the file opens at its default view.
let opened = ok(&mut a, "doc_open", json!({ "path": "manual.pdf#page=0" }));
assert_eq!(opened["pages"].as_u64(), Some(3));
assert_eq!((opened["destination_applied"].as_bool(), opened["destination"]["kind"].as_str()), (Some(true), Some("default")), "{opened}");
assert!(opened["warning"].is_null(), "{opened}");
// A file whose own name has a `#` opens as itself, and a plain path reports no destination.
let opened = ok(&mut a, "doc_open", json!({ "path": "odd#page=2.pdf" }));
assert_eq!((opened["name"].as_str(), opened["pages"].as_u64()), (Some("odd#page=2.pdf"), Some(1)));
assert!(opened.get("destination").is_none(), "{opened}");
// The file before the `#` must exist, inside --root.
let before = a.session().docs().len();
for path in ["missing.pdf#page=2", "../outside/secret.pdf#page=1"] {
err(&mut a, "doc_open", json!({ "path": path }));
assert_eq!(a.session().docs().len(), before, "{path}");
}
}
#[test]
fn local_gotor_link_open_applies_destinations_and_honors_root() {
let root = local_gotor_fixtures();
@@ -7405,6 +7451,7 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() {
assert_relative_gotor_destination(&mut a);
assert_absolute_gotor_policy(&mut a);
assert_unapplied_gotor_destinations(&mut a);
assert_named_gotor_destination(&mut a);
assert_encrypted_gotor_passwords(&mut a);
assert_unsupported_gotor_targets(&mut a);
assert_gotor_root_boundaries(&mut a);
@@ -7419,6 +7466,8 @@ fn local_gotor_fixtures() -> PathBuf {
std::fs::write(root.join("sub/source.pdf"), remote_link_pdf("../target.pdf", Some("[2 /FitH 180]"))).unwrap();
std::fs::write(root.join("sub/escape.pdf"), remote_link_pdf("../../outside/secret.pdf", Some("[0 /Fit]"))).unwrap();
std::fs::write(root.join("named.pdf"), remote_link_pdf("target.pdf", Some("(chapter)"))).unwrap();
std::fs::write(root.join("named-target.pdf"), fixture_with(3, NAMED_DESTS)).unwrap();
std::fs::write(root.join("named-found.pdf"), remote_link_pdf("named-target.pdf", Some("(chapter)"))).unwrap();
std::fs::write(root.join("range.pdf"), remote_link_pdf("target.pdf", Some("[99 /Fit]"))).unwrap();
std::fs::write(root.join("url.pdf"), remote_link_pdf("https://example.org/target.pdf", Some("[0 /Fit]"))).unwrap();
std::fs::write(root.join("missing-source.pdf"), remote_link_pdf("does-not-exist.pdf", Some("[0 /Fit]"))).unwrap();
@@ -7467,6 +7516,14 @@ fn assert_unapplied_gotor_destinations(a: &mut Automation) {
}
}
fn assert_named_gotor_destination(a: &mut Automation) {
// A name the target defines is looked up there, as the app does.
let source = ok(a, "doc_open", json!({ "path": "named-found.pdf" }))["doc"].as_u64().unwrap();
let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 }));
assert_eq!(opened["destination_applied"], true, "{opened}");
assert_eq!(opened["destination"], json!({ "kind": "named", "name": "chapter", "page": 2, "view": { "kind": "fit" } }));
}
fn assert_encrypted_gotor_passwords(a: &mut Automation) {
let password_source = ok(a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap();
let documents_before = a.session().docs().len();
+1
View File
@@ -78,6 +78,7 @@ family (`sign`, `sign_with_timestamp`, `timestamp_document`, `embed_ltv`).
| `catalog` | Acrobat's "All tools" information architecture as data — groups, sections, icons, hues, and an `Availability` of `Ready`, `Planned(milestone)` or `Provider`. The UI renders what is *not* built yet, honestly |
| `actions` | PDF action dispatch (go-to, URI, named, set-layer-state, JavaScript) |
| `links` | Link creation, properties, and finding URLs in page text |
| `open_parameters` | A path's PDF open parameters (`manual.pdf#nameddest=Intro`, `#page=12`): the file to open and where to show it. The app and `doc_open` both read them |
| `js` | The bridge to `pdfcraft-js`. `forms` never depends on `js` directly; the engine injects it, so a build without JavaScript is possible |
| `xfa`, `ocr`, `compare`, `export` | Façades over the L3/L4 crates of the same names |
+1
View File
@@ -40,6 +40,7 @@ pub mod model3d;
mod object_clip;
pub mod ocr;
mod open;
pub mod open_parameters;
pub mod optimizer;
mod page_filter;
mod pictures;
+101
View File
@@ -0,0 +1,101 @@
//! PDF open parameters on a path (#3425): help systems and other programs start a PDF reader with
//! `manual.pdf#nameddest=Intro` or `manual.pdf#page=12` to show one place in a document. The
//! parameters follow Adobe's public "Parameters for Opening PDF Files": `name=value` pairs after a
//! `#`, separated by `&` or `#`. PdfCraft reads `page` (1-based) and `nameddest`, and, as browsers'
//! PDF viewers do, a fragment without `=` as a destination name. Other parameters (`zoom`, `view`,
//! `pagemode`, `search`, …) are ignored, and the file still opens.
use pdfcraft_render::{DestView, RemoteDestination};
/// How many `#` of one path are tried as the end of its file name.
const MAX_CANDIDATES: usize = 32;
/// The file `path` names and the destination its open parameters ask for, when `path` is not a
/// file itself but what comes before one of its `#` is. The first such `#` counts, so a folder or
/// file name may contain `#` too. `None`: open `path` as it is.
pub fn split(path: &str, is_file: impl Fn(&str) -> bool) -> Option<(String, RemoteDestination)> {
if is_file(path) {
return None;
}
let (file, fragment) = path.match_indices('#').take(MAX_CANDIDATES).find_map(|(i, _)| {
// `#` is one byte, so `i` and `i + 1` are char boundaries.
let file = path.get(..i)?;
is_file(file).then(|| (file, path.get(i + 1..).unwrap_or_default()))
})?;
Some((file.to_string(), destination(fragment)))
}
/// The destination of a fragment's parameters; the last `page` or `nameddest` wins, as each
/// parameter is applied in turn. A page before the first, or one that isn't a number, is ignored.
fn destination(fragment: &str) -> RemoteDestination {
if !fragment.contains('=') {
return if fragment.is_empty() { RemoteDestination::Default } else { RemoteDestination::Named(fragment.to_string()) };
}
let mut destination = RemoteDestination::Default;
for (key, value) in fragment.split(['&', '#']).filter_map(|p| p.split_once('=')) {
if key.eq_ignore_ascii_case("page") {
if let Some(page) = value.trim().parse::<usize>().ok().and_then(|n| n.checked_sub(1)) {
destination = RemoteDestination::Page { page, view: DestView::Top };
}
} else if key.eq_ignore_ascii_case("nameddest") && !value.is_empty() {
destination = RemoteDestination::Named(value.to_string());
}
}
destination
}
#[cfg(test)]
mod tests {
use super::*;
fn files(names: &'static [&'static str]) -> impl Fn(&str) -> bool {
move |p| names.contains(&p)
}
fn page(page: usize) -> RemoteDestination {
RemoteDestination::Page { page, view: DestView::Top }
}
fn named(name: &str) -> RemoteDestination {
RemoteDestination::Named(name.to_string())
}
#[test]
fn a_named_destination_or_page_after_the_file_name_is_split_off() {
let manual = files(&[r"C:\Help\manual.pdf"]);
// #3425: DIgSILENT PowerFactory's F1 help.
assert_eq!(split(r"C:\Help\manual.pdf#nameddest=Load Flow", &manual), Some((r"C:\Help\manual.pdf".into(), named("Load Flow"))));
assert_eq!(split(r"C:\Help\manual.pdf#page=12", &manual), Some((r"C:\Help\manual.pdf".into(), page(11))));
// A bare fragment names a destination, as in a browser.
assert_eq!(split(r"C:\Help\manual.pdf#intro", &manual), Some((r"C:\Help\manual.pdf".into(), named("intro"))));
}
#[test]
fn several_parameters_apply_in_turn_and_unknown_ones_are_ignored() {
let pdf = files(&["/docs/a.pdf"]);
assert_eq!(split("/docs/a.pdf#page=3&zoom=200,0,0", &pdf), Some(("/docs/a.pdf".into(), page(2))));
assert_eq!(split("/docs/a.pdf#zoom=50#nameddest=x", &pdf), Some(("/docs/a.pdf".into(), named("x"))));
assert_eq!(split("/docs/a.pdf#nameddest=x&page=4", &pdf), Some(("/docs/a.pdf".into(), page(3))));
assert_eq!(split("/docs/a.pdf#PAGE=2", &pdf), Some(("/docs/a.pdf".into(), page(1))));
// The file opens at its default view when nothing can be applied.
for odd in
["/docs/a.pdf#", "/docs/a.pdf#page=0", "/docs/a.pdf#page=two", "/docs/a.pdf#page=-1", "/docs/a.pdf#nameddest=", "/docs/a.pdf#view=Fit"]
{
assert_eq!(split(odd, &pdf), Some(("/docs/a.pdf".into(), RemoteDestination::Default)), "{odd}");
}
assert_eq!(split("/docs/a.pdf#page=99999999999999999999999", &pdf), Some(("/docs/a.pdf".into(), RemoteDestination::Default)));
}
#[test]
fn a_path_that_names_a_file_is_left_alone() {
// A file whose name has a `#` in it, alone and with parameters of its own.
let hashed = files(&["/docs/C#.pdf", "/docs/#1/a.pdf"]);
assert_eq!(split("/docs/C#.pdf", &hashed), None);
assert_eq!(split("/docs/C#.pdf#page=2", &hashed), Some(("/docs/C#.pdf".into(), page(1))));
assert_eq!(split("/docs/#1/a.pdf#page=2", &hashed), Some(("/docs/#1/a.pdf".into(), page(1))));
// Nothing before a `#` is a file: the open fails on the path as given.
assert_eq!(split("/docs/missing.pdf#page=2", &hashed), None);
assert_eq!(split("/docs/plain.pdf", &hashed), None);
assert_eq!(split("#", &hashed), None);
}
}
+1
View File
@@ -40,6 +40,7 @@ pub use model::{
MAX_GROUP_DEPTH, OutlineItem, PageInfo, PageTransition, Popup, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa,
fill_group_text, group_primaries, link_replies, thread_tops,
};
pub use navigation::named_destination;
#[cfg(test)]
use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW};
#[cfg(test)]
+78 -3
View File
@@ -1,10 +1,10 @@
#[cfg(test)]
use super::core::inspect;
use super::core::spend;
use super::core::{load_options, spend};
use super::model::{DestView, LinkTarget, OutlineItem, PageInfo};
use super::{
BTreeMap, Dictionary, HashMap, HashSet, Inspector, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK,
MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, alpha, roman,
Arc, AssertUnwindSafe, BTreeMap, Dictionary, Document, HashMap, HashSet, Inspector, LazyStructure, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES,
MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, Structure, alpha, catch_unwind, roman,
};
/// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document
@@ -72,6 +72,17 @@ impl<'a> Inspector<'a> {
catalog.get(b"Dests").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(key).ok())
}
/// The destination whose key, read as a text string (UTF-16 or PDFDocEncoding), is `name`:
/// a name tree key, then a key of the PDF 1.1 `/Dests` dictionary, whose names older files
/// write in PDFDocEncoding rather than UTF-8.
fn named_dest_by_text(&self, name: &str) -> Option<&'a Object> {
if let Some((_, dest)) = self.named.iter().find(|(key, _)| text_string(key) == name) {
return Some(dest);
}
let dests = self.doc.catalog().ok()?.get(b"Dests").ok().and_then(|o| self.dict(o))?;
dests.iter().find(|(key, _)| text_string(key) == name).map(|(_, dest)| dest)
}
/// A name tree's leaves keyed by their raw key bytes (first occurrence wins).
pub(super) fn name_tree_raw(
&self,
@@ -460,6 +471,37 @@ fn label_work(left: &mut usize, count: usize) -> Result<(), &'static str> {
Ok(())
}
/// The (0-based page, view) of the named destination `name` (ISO 32000-2 §12.3.2.4): a key of
/// the `/Names /Dests` name tree or of the PDF 1.1 catalog `/Dests` dictionary, as a link's or
/// `/OpenAction`'s name resolves. `None` when the document can't be read, has no such name, or
/// the name leads to no page of it. A key matches `name` written in UTF-8 or in PDFDocEncoding,
/// or as a text string (UTF-16 or PDFDocEncoding) that reads as `name`, the way a link's string
/// destination is read.
/// Read like [`inspect`]: the lazy reader first, lopdf's tolerant one when the lazy reader fails.
pub fn named_destination(bytes: Arc<Vec<u8>>, password: Option<&str>, name: &str) -> Option<(usize, DestView)> {
// PDFDocEncoding bytes that are also UTF-8 read as UTF-8, so only this finds "é" written C3 A9.
let pdfdoc: Option<Vec<u8>> = name.chars().map(pdfcraft_cos::pdfdoc_byte).collect();
let find = |doc: &dyn Structure| {
let insp = Inspector::new(doc);
let dest = insp
.named_dest(name.as_bytes())
.or_else(|| pdfdoc.as_deref().and_then(|key| insp.named_dest(key)))
.or_else(|| insp.named_dest_by_text(name))?;
insp.dest(dest, 1).filter(|(page, _)| *page < insp.page_ids.len())
};
let lazy = catch_unwind(AssertUnwindSafe(|| {
let doc = LazyStructure::new(bytes.clone(), password).ok()?;
let found = find(&doc);
(!doc.failed()).then_some(found)
}))
.ok()
.flatten();
if let Some(found) = lazy {
return found;
}
catch_unwind(AssertUnwindSafe(|| find(&Document::load_mem_with_options(&bytes, load_options(password)).ok()?))).ok().flatten()
}
#[cfg(test)]
mod open_dest_tests {
use std::sync::Arc;
@@ -522,6 +564,39 @@ mod open_dest_tests {
);
}
fn named(catalog: &str, extra: &[String], name: &str) -> Option<(usize, DestView)> {
super::named_destination(Arc::new(pdf(catalog, extra)), None, name)
}
#[test]
fn a_named_destination_is_looked_up_by_its_name() {
// A string key of the /Names /Dests name tree, whose value may be a dictionary with /D.
let tree = "/Names << /Dests 6 0 R >>";
let nodes = ["<< /Kids [7 0 R] >>".to_string(), "<< /Names [(here) [3 0 R /Fit] (there) << /D [5 0 R /XYZ 100 500 2] >>] >>".to_string()];
assert_eq!(named(tree, &nodes, "there"), Some((2, XYZ)));
assert_eq!(named(tree, &nodes, "here"), Some((0, DestView::Fit)));
// A name key of the PDF 1.1 catalog /Dests dictionary.
assert_eq!(named("/Dests << /old [4 0 R /FitH 400] >>", &[], "old"), Some((1, DestView::FitH { top: Some(400.0) })));
// A key written as UTF-16 or in PDFDocEncoding matches the text it reads as.
assert_eq!(named("/Names << /Dests << /Names [<FEFF0074006800E9> [4 0 R /Fit]] >> >>", &[], "thé"), Some((1, DestView::Fit)));
assert_eq!(named("/Names << /Dests << /Names [(th\\351) [5 0 R /Fit]] >> >>", &[], "thé"), Some((2, DestView::Fit)));
// So does a catalog /Dests name written in PDFDocEncoding, as older files have them, but
// a name written as UTF-8 comes first.
assert_eq!(named("/Dests << /th#E9 [4 0 R /Fit] >>", &[], "thé"), Some((1, DestView::Fit)));
assert_eq!(named("/Dests << /th#E9 [4 0 R /Fit] /th#C3#A9 [5 0 R /Fit] >>", &[], "thé"), Some((2, DestView::Fit)));
// A key whose PDFDocEncoding bytes are also UTF-8 reads as UTF-8 ("é"), yet is found by
// the text it holds in PDFDocEncoding ("é") too.
assert_eq!(named("/Dests << /#C3#A9 [4 0 R /Fit] >>", &[], "é"), Some((1, DestView::Fit)));
assert_eq!(named("/Names << /Dests << /Names [(\\303\\251) [5 0 R /Fit]] >> >>", &[], "é"), Some((2, DestView::Fit)));
// A name that isn't there, one past the last page, one that names itself, no names at
// all, and bytes that aren't a PDF.
assert_eq!(named(tree, &nodes, "missing"), None);
assert_eq!(named("/Dests << /far [7 /Fit] >>", &[], "far"), None);
assert_eq!(named("/Dests << /loop /loop >>", &[], "loop"), None);
assert_eq!(named("", &[], "there"), None);
assert_eq!(super::named_destination(Arc::new(b"not a pdf".to_vec()), None, "there"), None);
}
#[test]
fn an_open_action_given_as_a_name_or_string_is_looked_up() {
assert_eq!(open_dest("/OpenAction /there /Dests << /there [4 0 R /Fit] >>", &[]), Some((1, DestView::Fit)));
+1 -1
View File
@@ -21,7 +21,7 @@ pub mod transition;
pub use inspect::{
Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerItem, LayerOp, LayerRow, Link, LinkTarget,
MAX_GROUP_DEPTH, OutlineItem, PageInfo, PageTransition, Popup, Portfolio, PortfolioView, RemoteDestination, ReviewStates, TransitionStyle, Xfa,
attachment_data, check_pages, fill_group_text, group_primaries, inspect, link_replies, pretty_date, thread_tops,
attachment_data, check_pages, fill_group_text, group_primaries, inspect, link_replies, named_destination, pretty_date, thread_tops,
};
pub use link_area::{MAX_LINK_QUADS, MAX_URI_BASE_BYTES, link_quads, quad_contains, resolve_uri};
pub use pixels::Pixels;
+3
View File
@@ -164,6 +164,8 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
password_retry: None,
#[cfg(not(target_arch = "wasm32"))]
pending_destinations: Vec::new(),
#[cfg(not(target_arch = "wasm32"))]
next_open_token: 0,
#[cfg(not(target_arch = "wasm32"))]
launch_layout: None,
@@ -594,6 +596,7 @@ impl PdfCraftApp {
worker.set_context(ctx);
}
self.poll_pending_opens();
self.poll_pending_destinations();
}
self.check_restored_recent();
// Chinese wants its own faces before the Japanese ones (one baseline per line).
+41 -5
View File
@@ -156,7 +156,7 @@ impl PdfCraftApp {
let mut queue = std::mem::take(&mut self.deferred_opens);
while self.deferred_opens.is_empty() && self.pending_opens.len() < self.open_slots() {
let Some(next) = queue.pop_front() else { break };
self.open_path_request(&next.path, next.then, next.display_override);
self.open_file_request(&next.path, next.after_open, next.then, next.display_override);
}
// A file put back (the queue filled again) stays ahead of the ones behind it.
self.deferred_opens.append(&mut queue);
@@ -802,6 +802,24 @@ impl PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
fn open_path_request(&mut self, path: &str, then: Option<&'static str>, display_override: Option<(canvas::PageLayout, bool)>) -> OpenOutcome {
// `manual.pdf#nameddest=Intro`, as a help system starts a PDF reader (#3425): the file,
// shown at that destination once open. A path that names a file is never split.
match pdfcraft_engine::open_parameters::split(path, |p| std::path::Path::new(p).is_file()) {
Some((file, destination)) => self.open_file_request(&file, Some(destination), then, display_override),
None => self.open_file_request(path, None, then, display_override),
}
}
/// Open the file `path`, shown at `after_open` once open. A file that waits for room in the
/// worker's queue keeps its destination with it, as its path is the file alone (#3425).
#[cfg(not(target_arch = "wasm32"))]
fn open_file_request(
&mut self,
path: &str,
after_open: Option<pdfcraft_render::RemoteDestination>,
then: Option<&'static str>,
display_override: Option<(canvas::PageLayout, bool)>,
) -> OpenOutcome {
// One file, one tab (#2166): a file that is open already comes to the front instead,
// and the tool it was opened for runs on it. One still being opened is left to that.
if let Some(i) = self.tab_showing(path) {
@@ -813,6 +831,9 @@ impl PdfCraftApp {
view.set_display(layout, continuous);
self.launch_display_target = Some(view.id);
}
if let Some(destination) = after_open {
self.apply_destination_at(i, destination);
}
if let Some(command) = then {
self.execute(command);
}
@@ -822,14 +843,22 @@ impl PdfCraftApp {
if display_override.is_some() {
pending.display_override = display_override;
}
// Asked for another place before it finished opening: it opens at the latest one.
if after_open.is_some() {
pending.after_open = after_open;
}
return OpenOutcome::Pending;
}
// The same file named again while its password is being asked for joins that prompt
// instead of queuing a second one; a display override or a tool it brings is kept.
// instead of queuing a second one; a display override, a place to open at or a tool it
// brings is kept.
if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) {
if display_override.is_some() {
prompt.display_override = display_override;
}
if after_open.is_some() {
prompt.after_open = after_open;
}
if then.is_some() {
prompt.then = then;
}
@@ -839,6 +868,9 @@ impl PdfCraftApp {
if display_override.is_some() {
deferred.display_override = display_override;
}
if after_open.is_some() {
deferred.after_open = after_open;
}
if then.is_some() {
deferred.then = then;
}
@@ -865,9 +897,10 @@ impl PdfCraftApp {
if let Some(prompt) = self.password_prompt.as_mut().filter(|p| {
p.path.as_deref() == Some(path) && previous_prompt.as_ref().is_none_or(|bytes| !std::sync::Arc::ptr_eq(bytes, &p.bytes))
}) {
// Encrypted: the tool runs once the password is in.
// Encrypted: the destination, the layout and the tool wait for the password.
prompt.then = then;
prompt.display_override = display_override;
prompt.after_open = after_open;
} else if self.views.len() > before {
if let Some((layout, continuous)) = display_override
&& let Some(view) = self.views.last_mut()
@@ -878,6 +911,9 @@ impl PdfCraftApp {
if let Some(doc) = self.views.last().map(|view| view.id) {
self.watch.known_as(doc, stamp);
}
if let Some(destination) = after_open {
self.apply_remote_destination(destination);
}
if let Some(command) = then {
self.execute(command);
}
@@ -898,7 +934,7 @@ impl PdfCraftApp {
// The worker queue is bounded. A long file list (a launch behind a password prompt,
// or many files at once) waits here, in order, and is submitted as opens finish.
if self.pending_opens.len() >= self.open_slots() || !self.deferred_opens.is_empty() {
self.deferred_opens.push_back(DeferredOpen { path: path.to_string(), then, display_override });
self.deferred_opens.push_back(DeferredOpen { path: path.to_string(), after_open, then, display_override });
return OpenOutcome::Pending;
}
match self.schedule_open(OpenRequest {
@@ -908,7 +944,7 @@ impl PdfCraftApp {
input: open_worker::Input::Path(std::path::PathBuf::from(path)),
password: None,
activate: true,
after_open: None,
after_open,
recovered: None,
then,
}) {
+69 -4
View File
@@ -66,13 +66,69 @@ impl PdfCraftApp {
}
pub(super) fn apply_remote_destination(&mut self, destination: pdfcraft_render::RemoteDestination) {
let Some(index) = self.views.len().checked_sub(1) else { return };
let Some(doc) = self.session.get(self.views[index].id) else { return };
if let Some(index) = self.views.len().checked_sub(1) {
self.apply_destination_at(index, destination);
}
}
/// Show `destination` in the tab at `index`: a document just opened from a link or with open
/// parameters (#3425), or one already open that such a link or launch names. The latest request
/// for a document wins over a named destination still being looked up.
pub(super) fn apply_destination_at(&mut self, index: usize, destination: pdfcraft_render::RemoteDestination) {
let Some(id) = self.views.get(index).map(|view| view.id) else { return };
#[cfg(not(target_arch = "wasm32"))]
self.pending_destinations.retain(|pending| pending.doc != id);
let destination = match destination {
pdfcraft_render::RemoteDestination::Named(name) => {
let Some(doc) = self.session.get(id) else { return };
let (bytes, password) = (doc.bytes.clone(), doc.password.clone());
// Live, the name tree is read off the UI thread; startup and tests stay inline.
#[cfg(not(target_arch = "wasm32"))]
if let Some(ctx) = self.ctx.clone().filter(|_| !self.run_inline) {
let (sender, receiver) = std::sync::mpsc::channel();
let (work_bytes, work_password, work_name) = (bytes.clone(), password.clone(), name.clone());
let work = move || {
let _ = sender.send(pdfcraft_render::named_destination(work_bytes, work_password.as_deref(), &work_name));
ctx.request_repaint();
};
if std::thread::Builder::new().name("pdfcraft-named-destination".into()).spawn(work).is_ok() {
self.pending_destinations.push(PendingDestination { doc: id, name, receiver });
return;
}
}
found_destination(pdfcraft_render::named_destination(bytes, password.as_deref(), &name), name)
}
other => other,
};
self.show_destination_at(index, destination);
}
/// Show each named destination whose lookup has finished, in its document's tab.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn poll_pending_destinations(&mut self) {
for pending in std::mem::take(&mut self.pending_destinations) {
let found = match pending.receiver.try_recv() {
Ok(found) => found,
Err(std::sync::mpsc::TryRecvError::Empty) => {
self.pending_destinations.push(pending);
continue;
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => None,
};
if let Some(index) = self.views.iter().position(|view| view.id == pending.doc) {
self.show_destination_at(index, found_destination(found, pending.name));
}
}
}
fn show_destination_at(&mut self, index: usize, destination: pdfcraft_render::RemoteDestination) {
let Some(view) = self.views.get_mut(index) else { return };
let Some(doc) = self.session.get(view.id) else { return };
let warning = match destination {
pdfcraft_render::RemoteDestination::Default => None,
pdfcraft_render::RemoteDestination::Named(_) => Some("The linked PDF opened, but its named destination was not applied."),
pdfcraft_render::RemoteDestination::Page { page, view } if page < doc.info.pages.len() => {
self.views[index].go_to_dest(page, view, &doc.info);
pdfcraft_render::RemoteDestination::Page { page, view: dest } if page < doc.info.pages.len() => {
view.go_to_dest(page, dest, &doc.info);
None
}
pdfcraft_render::RemoteDestination::Page { .. } => Some("The linked PDF opened, but its page destination was out of range."),
@@ -123,3 +179,12 @@ impl PdfCraftApp {
}
}
}
/// A named destination's lookup result as a destination to show: its page, or the name again when
/// the document doesn't define it, which shows a warning.
fn found_destination(found: Option<(usize, pdfcraft_render::DestView)>, name: String) -> pdfcraft_render::RemoteDestination {
match found {
Some((page, view)) => pdfcraft_render::RemoteDestination::Page { page, view },
None => pdfcraft_render::RemoteDestination::Named(name),
}
}
+12
View File
@@ -281,6 +281,7 @@ struct PendingOpen {
#[cfg(not(target_arch = "wasm32"))]
struct DeferredOpen {
path: String,
after_open: Option<pdfcraft_render::RemoteDestination>,
then: Option<&'static str>,
display_override: Option<(canvas::PageLayout, bool)>,
}
@@ -289,6 +290,15 @@ struct DeferredOpen {
/// used instead.
pub type HtmlClipboard = Box<dyn FnMut(&str, &str) -> bool>;
/// A named destination being looked up off the UI thread: reading a large document's name tree
/// takes a noticeable moment.
#[cfg(not(target_arch = "wasm32"))]
struct PendingDestination {
doc: DocId,
name: String,
receiver: std::sync::mpsc::Receiver<Option<(usize, pdfcraft_render::DestView)>>,
}
pub struct PdfCraftApp {
/// Split view (`panes`); which pane shows each tab is its view's.
pub panes: panes::Panes,
@@ -484,6 +494,8 @@ pub struct PdfCraftApp {
#[cfg(not(target_arch = "wasm32"))]
password_retry: Option<u64>,
#[cfg(not(target_arch = "wasm32"))]
pending_destinations: Vec<PendingDestination>,
#[cfg(not(target_arch = "wasm32"))]
next_open_token: u64,
/// Explicit CLI layout waits for flagged launch opens, never targeting restored tabs.
#[cfg(not(target_arch = "wasm32"))]
@@ -914,7 +914,7 @@ fn a_repeated_waiting_open_keeps_the_tool_it_brings() {
let file = tool_open_file("deferred-tool", "pdf", &fixture(1));
let path = file.0.to_string_lossy().into_owned();
let mut app = PdfCraftApp::new();
app.deferred_opens.push_back(crate::DeferredOpen { path: path.clone(), then: None, display_override: None });
app.deferred_opens.push_back(crate::DeferredOpen { path: path.clone(), after_open: None, then: None, display_override: None });
assert_eq!(app.open_path_then(&path, Some("export.text")), crate::OpenOutcome::Pending);
assert_eq!(app.deferred_opens.len(), 1);
assert_eq!(app.deferred_opens.front().and_then(|d| d.then), Some("export.text"));
+46
View File
@@ -323,6 +323,52 @@ fn a_local_gotor_link_opens_in_a_tab_and_applies_its_destination() {
assert_eq!(h.state().last_opened_url, None, "no URL or external launcher is involved");
}
/// Three 300×400 pages whose catalog /Dests names `there` (page 3) and `here` (page 2).
fn named_pages() -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /Dests << /there [5 0 R /Fit] /here [4 0 R /Fit] >> >>",
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>",
"<< /Type /Page /Parent 2 0 R >>",
"<< /Type /Page /Parent 2 0 R >>",
"<< /Type /Page /Parent 2 0 R >>",
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_gotor_link_to_a_named_destination_opens_at_its_page() {
let _gpu = gpu();
let dir = std::env::temp_dir().join(format!("pdfcraft-ui-gotor-named-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("target.pdf"), named_pages()).unwrap();
for (destination, page) in [("/there", 2), ("(here)", 1)] {
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", destination);
click_remote_link(&mut h);
// The name is looked up off the UI thread, so the tab may open before it moves there.
support::wait_until(&mut h, "remote document at its destination", |app| app.views.len() == 2 && app.views[1].current == page);
assert_eq!(h.state().toast, None, "{destination}: applied, so nothing to warn about");
}
// A name the target doesn't define opens it at its first page, with the warning.
let mut h = remote_harness(Some(dir.join("source.pdf").to_string_lossy().into_owned()), "target.pdf", "/elsewhere");
click_remote_link(&mut h);
support::wait_until(&mut h, "remote document and the warning", |app| app.views.len() == 2 && app.toast.is_some());
assert_eq!(h.state().views[1].current, 0);
assert!(h.state().toast.as_ref().is_some_and(|(text, _)| text.contains("named destination was not applied")), "{:?}", h.state().toast);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_remote_destination_survives_password_retries_and_cancel_opens_no_tab() {
+119 -1
View File
@@ -6,7 +6,7 @@ use egui::{Key, Modifiers};
use egui_kittest::Harness;
use pdfcraft_render::DestView;
use pdfcraft_ui_egui::canvas::Fit;
use pdfcraft_ui_egui::{PdfCraftApp, RightPanel};
use pdfcraft_ui_egui::{OpenOutcome, PdfCraftApp, RightPanel};
/// Six Letter (612×792) pages, objects 3 to 8; `catalog` is added to the catalog dictionary.
/// Object 9 is an outline (one bookmark, object 10) that `/Outlines 9 0 R` can use.
@@ -189,3 +189,121 @@ fn a_full_screen_document_with_an_outline_shows_it_only_when_it_asks_to() {
let h = harness("/Outlines 9 0 R");
assert_eq!(h.state().right, Some(RightPanel::Bookmarks));
}
/// Named destinations for open parameters: `intro` (page 2) in the catalog's /Dests and
/// `Load Flow` (page 4) in the /Names /Dests tree. The document itself opens on page 3.
const NAMED: &str = "/OpenAction [5 0 R /Fit] /Dests << /intro [4 0 R /Fit] >> /Names << /Dests << /Names [(Load Flow) [6 0 R /XYZ 0 792 0]] >> >>";
/// `fixture(NAMED)` saved as `manual.pdf` in a folder of its own, whose path is returned.
fn manual(test: &str) -> String {
let dir = std::env::temp_dir().join(format!("pdfcraft-open-parameters-{test}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("manual.pdf");
std::fs::write(&path, fixture(NAMED)).unwrap();
path.to_string_lossy().into_owned()
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_file_named_with_open_parameters_opens_at_their_destination() {
// #3425: a help system starts PdfCraft with `manual.pdf#nameddest=…`. Files named at launch
// open before the first frame.
let path = manual("launch");
for (parameters, page) in [("#nameddest=Load Flow", 3), ("#nameddest=intro", 1), ("#intro", 1), ("#page=5", 4), ("#zoom=200", 2)] {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
assert_eq!(app.open_path(&format!("{path}{parameters}")), OpenOutcome::Opened, "{parameters}");
assert_eq!(app.views.len(), 1, "{parameters}");
assert_eq!(app.views[0].current, page, "{parameters}");
assert_eq!(app.session.get(app.views[0].id).and_then(|d| d.path.clone()).as_deref(), Some(path.as_str()), "{parameters}: the file itself");
assert_eq!(app.toast, None, "{parameters}");
}
// A name the file doesn't define: it opens where it would have, and says so.
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
assert_eq!(app.open_path(&format!("{path}#nameddest=Nowhere")), OpenOutcome::Opened);
assert_eq!(app.views[0].current, 2);
assert!(app.toast.as_ref().is_some_and(|(text, _)| text.contains("named destination was not applied")), "{:?}", app.toast);
// A path that names no file, before a `#` or as a whole, still fails as before.
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
assert_eq!(app.open_path(&format!("{path}.missing#page=2")), OpenOutcome::Failed);
assert!(app.views.is_empty());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn open_parameters_handed_to_the_running_app_show_their_destination() {
// A second launch hands `manual.pdf#…` to the running app, which opens it in the background.
let path = manual("running");
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app
});
h.run_steps(4);
// Asked twice before it has opened, it opens at the place asked for last.
assert_eq!(h.state_mut().open_path(&format!("{path}#page=5")), OpenOutcome::Pending);
assert_eq!(h.state_mut().open_path(&format!("{path}#nameddest=Load Flow")), OpenOutcome::Pending);
crate::support::wait_until(&mut h, "the manual at Load Flow", |app| app.views.len() == 1 && app.views[0].current == 3);
// Asked again for a place in the open file, its tab goes there instead of opening it again.
assert_eq!(h.state_mut().open_path(&format!("{path}#page=2")), OpenOutcome::Focused);
assert_eq!((h.state().views.len(), h.state().views[0].current), (1, 1));
// A name is looked up off the UI thread: the tab moves once the lookup is in.
assert_eq!(h.state_mut().open_path(&format!("{path}#nameddest=Load Flow")), OpenOutcome::Focused);
assert_eq!(h.state().views[0].current, 1, "not yet");
crate::support::wait_until(&mut h, "Load Flow", |app| app.views[0].current == 3);
// A page asked for while a name is still being looked up wins: the lookup's answer is dropped.
assert_eq!(h.state_mut().open_path(&format!("{path}#nameddest=intro")), OpenOutcome::Focused);
assert_eq!(h.state_mut().open_path(&format!("{path}#page=5")), OpenOutcome::Focused);
for _ in 0..25 {
h.run_steps(1);
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert_eq!(h.state().views[0].current, 4);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn open_parameters_wait_with_their_file_behind_a_password_prompt_and_a_full_queue() {
// Launched with a protected file first and more files than the open worker queues at once:
// the files past its bound wait their turn, and the later ones wait behind the password
// prompt. A file named with open parameters keeps them while it waits, and named again, it
// opens at the place asked for last; the protected file named again with them while its
// password is asked for opens there once unlocked. They opened where they would have.
let dir = std::env::temp_dir().join(format!("pdfcraft-open-parameters-queue-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let file = |name: &str, bytes: Vec<u8>| {
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
};
let locked = file("locked.pdf", crate::editing::encrypted(fixture(NAMED), pdfcraft_cos::Algorithm::Aes256, "reader", "owner", -4));
let others: Vec<String> = (0..16).map(|i| file(&format!("other-{i:02}.pdf"), fixture(""))).collect();
let manual = file("manual.pdf", fixture(NAMED));
let (first, rest, last) = (locked.clone(), others.clone(), manual.clone());
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_path(&first);
for path in &rest {
app.open_path(path);
}
assert_eq!(app.open_path(&format!("{last}#page=2")), OpenOutcome::Pending);
assert_eq!(app.open_path(&format!("{last}#page=5")), OpenOutcome::Pending);
assert_eq!(app.open_path(&format!("{first}#page=4")), OpenOutcome::Pending);
app
});
h.run_steps(4);
assert!(h.state().password_prompt.is_some(), "the protected file is asked for");
h.state_mut().submit_password(Some("reader".into()));
crate::support::wait_until(&mut h, "every file", |app| app.views.len() == others.len() + 2);
let app = h.state();
let page_of = |path: &str| app.views.iter().find(|v| app.session.get(v.id).and_then(|d| d.path.as_deref()) == Some(path)).map(|v| v.current);
// `fixture(NAMED)` opens at page 3 by its /OpenAction.
assert_eq!(page_of(&manual), Some(4), "the file past the queue's bound, at the last page asked for");
assert_eq!(page_of(&locked), Some(3), "the protected file, at the page asked for during its prompt");
let _ = std::fs::remove_dir_all(&dir);
}
+4 -4
View File
@@ -34,8 +34,8 @@ milestone = "M1"
status = "shipped"
commands = ["file.open"]
tools = ["doc_open"]
evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/platform/open_worker/tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits", "crates/ui-egui/tests/it/editing.rs::opening_the_same_path_again_focuses_its_tab", "crates/ui-egui/tests/it/editing.rs::opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab"]
notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type); an already-open file focuses its tab instead of opening a second copy (#1620). Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)"
evidence = ["crates/automation/tests/it/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/it/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/it/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/src/platform/open_worker/tests.rs::a_chosen_tool_runs_on_its_file_only_after_native_preparation_commits", "crates/ui-egui/tests/it/editing.rs::opening_the_same_path_again_focuses_its_tab", "crates/ui-egui/tests/it/editing.rs::opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab", "crates/ui-egui/tests/it/open_action.rs::a_file_named_with_open_parameters_opens_at_their_destination", "crates/ui-egui/tests/it/open_action.rs::open_parameters_handed_to_the_running_app_show_their_destination", "crates/engine/src/open_parameters.rs::several_parameters_apply_in_turn_and_unknown_ones_are_ignored", "crates/automation/tests/it/automation.rs::doc_open_reads_open_parameters_after_the_file_name"]
notes = "File ▸ Open, the command line (Linux and Windows file associations), the Windows MSI's \"Edit with PdfCraft\" entry on PDF files' File Explorer right-click menu (#682; several selected PDFs open as tabs of one window), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type); an already-open file focuses its tab instead of opening a second copy (#1620). A path that ends in open parameters, as help systems pass them (`manual.pdf#nameddest=Intro`, `#page=12`, or `#Intro`; #3425), opens the file at that destination, or goes there in its tab when it is open, and `doc_open` reports that destination; other parameters (zoom, view, pagemode, search…) are ignored. Not yet: a first-level Windows 11 menu entry (needs package identity) or Combine from the file manager (#585)"
[[feature]]
id = "core.repair-broken-files"
@@ -1202,8 +1202,8 @@ milestone = "M3"
status = "shipped"
commands = []
tools = ["link_open"]
evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/ui-egui/tests/it/view.rs::a_link_without_a_position_keeps_the_place_on_the_next_page", "crates/ui-egui/src/canvas/tests.rs::a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page", "crates/render/src/inspect/tests.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect/tests.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect/tests.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link", "crates/render/src/inspect/tests.rs::relative_uri_actions_resolve_against_the_catalog_base", "crates/render/src/inspect/tests.rs::links_are_active_in_their_quad_points", "crates/automation/tests/it/automation.rs::links_resolve_relative_addresses_and_report_their_quad_points"]
notes = "A relative URI action resolves against the catalog's /URI /Base (ISO 32000-2 Table 211) when that base is an http(s) address, then goes through the usual check and confirmation; without a base, a relative address is still refused (resolving against the document's own location is not done). A link with valid /QuadPoints is active only inside them, otherwise over /Rect (Table 176); link_list reports resolved_url and active_areas. IsMap is not supported. GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. Named remote destinations open at default view with a warning; /NewWindow is a tab hint. A Launch action whose /F names a .pdf file (and has no /Win launch parameters) opens that PDF the same way, backslash separators read as / (#4168); URL/network file systems, GoToE, other Launch actions and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit. A null /XYZ left or top keeps the reader's place, as ISO 32000-2 §12.3.2.2 says and Acrobat Reader was observed to do (synthetic fixture): the user-space point at the window's top-left stays there on the target page at the same zoom, along each axis the page overflows; CAD exports (WSCAD) write every cross-reference this way. Null /FitH top and /FitV left still go to the page edge."
evidence = ["crates/ui-egui/tests/it/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/it/view.rs::link_destinations_scroll_to_their_position", "crates/ui-egui/tests/it/view.rs::a_link_without_a_position_keeps_the_place_on_the_next_page", "crates/ui-egui/src/canvas/tests.rs::a_null_xyz_coordinate_keeps_the_readers_place_on_the_new_page", "crates/render/src/inspect/tests.rs::bookmarks_and_links_keep_their_destination_view", "crates/render/src/inspect/tests.rs::links_read_bounded_local_remote_go_to_actions", "crates/automation/tests/it/automation.rs::local_gotor_link_open_applies_destinations_and_honors_root", "crates/ui-egui/tests/it/document_links.rs::a_local_gotor_link_opens_in_a_tab_and_applies_its_destination", "crates/ui-egui/tests/it/document_links.rs::a_gotor_link_to_a_named_destination_opens_at_its_page", "crates/render/src/inspect/navigation.rs::a_named_destination_is_looked_up_by_its_name", "crates/ui-egui/tests/it/document_links.rs::an_absolute_gotor_waits_for_consent_and_keeps_its_destination_across_tabs", "crates/ui-egui/tests/it/document_links.rs::cancelling_an_absolute_gotor_by_button_escape_or_dismissal_changes_nothing", "crates/automation/tests/it/automation.rs::absolute_gotor_consent_does_not_bypass_validation_or_passwords", "crates/render/src/inspect/tests.rs::launch_actions_that_name_a_pdf_open_it_like_a_remote_link", "crates/automation/tests/it/automation.rs::a_launch_link_to_a_pdf_opens_it_like_a_remote_link", "crates/render/src/inspect/tests.rs::relative_uri_actions_resolve_against_the_catalog_base", "crates/render/src/inspect/tests.rs::links_are_active_in_their_quad_points", "crates/automation/tests/it/automation.rs::links_resolve_relative_addresses_and_report_their_quad_points"]
notes = "A relative URI action resolves against the catalog's /URI /Base (ISO 32000-2 Table 211) when that base is an http(s) address, then goes through the usual check and confirmation; without a base, a relative address is still refused (resolving against the document's own location is not done). A link with valid /QuadPoints is active only inside them, otherwise over /Rect (Table 176); link_list reports resolved_url and active_areas. IsMap is not supported. GoTo and URI links plus local-PDF GoToR links: source-relative or local absolute /F paths, /UF preferred in file-spec dictionaries, explicit numeric page/view destinations, and internal tabs. Absolute local GoToR targets require desktop Allow/Cancel confirmation or an explicit allow_absolute opt-in in link_open; relative links open normally. link_open honors --root. A named remote destination opens at its page and view, in the app and in link_open, looked up in the target's /Names /Dests tree or catalog /Dests (§12.3.2.4); a name the target doesn't define opens it at the default view with a warning; /NewWindow is a tab hint. A Launch action whose /F names a .pdf file (and has no /Win launch parameters) opens that PDF the same way, backslash separators read as / (#4168); URL/network file systems, GoToE, other Launch actions and other actions are not executed. Same-document destinations retain their supported /XYZ, /Fit, /FitH, /FitV and /FitR views; /FitB* is read as whole-page fit. A null /XYZ left or top keeps the reader's place, as ISO 32000-2 §12.3.2.2 says and Acrobat Reader was observed to do (synthetic fixture): the user-space point at the window's top-left stays there on the target page at the same zoom, along each axis the page overflows; CAD exports (WSCAD) write every cross-reference this way. Null /FitH top and /FitV left still go to the page edge."
[[feature]]
id = "view.annotation-hover"