Skip to main content

pdfops/
pagespec.rs

1//! Page selection syntax shared by every command.
2//!
3//! Comma separated parts, 1-based: `3`, `2-5`, `7-` (to end), `-4` (from start),
4//! `5-2` (descending), `last`, `odd`, `even`, `all`.
5
6use anyhow::{Result, bail};
7
8/// Expands `spec` into page numbers, in the order written. Duplicates are kept.
9pub fn parse(spec: &str, total: u32) -> Result<Vec<u32>> {
10    if total == 0 {
11        bail!("document has no pages");
12    }
13    let mut out = Vec::new();
14    for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) {
15        match part.to_ascii_lowercase().as_str() {
16            "all" => out.extend(1..=total),
17            "odd" => out.extend((1..=total).step_by(2)),
18            "even" => out.extend((2..=total).step_by(2)),
19            p => match p.split_once('-') {
20                None => out.push(bound(p, total)?),
21                Some((a, b)) => {
22                    let start = if a.trim().is_empty() {
23                        1
24                    } else {
25                        bound(a, total)?
26                    };
27                    let end = if b.trim().is_empty() {
28                        total
29                    } else {
30                        bound(b, total)?
31                    };
32                    if start <= end {
33                        out.extend(start..=end);
34                    } else {
35                        out.extend((end..=start).rev());
36                    }
37                }
38            },
39        }
40    }
41    if out.is_empty() {
42        bail!("page spec '{spec}' selects no pages");
43    }
44    Ok(out)
45}
46
47/// `spec` if given, otherwise every page.
48pub fn parse_or_all(spec: Option<&str>, total: u32) -> Result<Vec<u32>> {
49    match spec {
50        Some(s) => parse(s, total),
51        None => parse("all", total),
52    }
53}
54
55fn bound(s: &str, total: u32) -> Result<u32> {
56    let s = s.trim();
57    if s == "last" {
58        return Ok(total);
59    }
60    let n: u32 = s
61        .parse()
62        .map_err(|_| anyhow::anyhow!("invalid page '{s}'"))?;
63    if n == 0 || n > total {
64        bail!("page {n} out of range 1-{total}");
65    }
66    Ok(n)
67}
68
69#[cfg(test)]
70mod tests {
71    use super::parse;
72
73    #[test]
74    fn singles_ranges_and_keywords() {
75        assert_eq!(parse("1,3-5", 10).unwrap(), [1, 3, 4, 5]);
76        assert_eq!(parse("8-", 10).unwrap(), [8, 9, 10]);
77        assert_eq!(parse("-2, last", 10).unwrap(), [1, 2, 10]);
78        assert_eq!(parse("3-1", 10).unwrap(), [3, 2, 1]);
79        assert_eq!(parse("odd", 5).unwrap(), [1, 3, 5]);
80        assert_eq!(parse("even", 5).unwrap(), [2, 4]);
81        assert_eq!(parse("all", 3).unwrap(), [1, 2, 3]);
82        assert_eq!(parse("2,2", 3).unwrap(), [2, 2]);
83    }
84
85    #[test]
86    fn rejects_bad_input() {
87        assert!(parse("0", 5).is_err());
88        assert!(parse("6", 5).is_err());
89        assert!(parse("a-b", 5).is_err());
90        assert!(parse("", 5).is_err());
91        assert!(parse("even", 1).is_err());
92        assert!(parse("1", 0).is_err());
93    }
94}