Files
pdfcraft/crates/engine/src/open_parameters.rs
T

102 lines
5.1 KiB
Rust

//! 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);
}
}