Skip to main content

pdfrum_edit/import/
range.rs

1//! The page-range grammar viewers accept: `"1,3-5"`.
2//!
3//! # It is not the grammar you would design
4//!
5//! Two rules surprise everyone who reads the output before the code.
6//!
7//! **All spaces are stripped globally, including inside numbers.** So
8//! `"5  0, 1-2"` becomes `"50,1-2"` and selects page 50, then 1, then 2 —
9//! not page 5. The C++'s own unit test annotates this behavior with a
10//! literal `// ???`, and it is pinned here because files and command lines in
11//! the wild depend on `"1- 4"` and `"1 -4"` both meaning `1-4`.
12//!
13//! **Failure is all-or-nothing.** One bad entry discards the entire range,
14//! rather than being skipped. `"1,2,clams"` selects nothing at all.
15//!
16//! Duplicates and descending order are both legal: `"1-4,3-6"` really does
17//! select eight pages with two repeats, and `"2,1"` selects page 2 before
18//! page 1. The result is a *sequence*, not a set.
19
20use pdfrum_common::PageIndex;
21
22use crate::error::Error;
23
24/// A parsed page range: zero-based page indices, in the order named, with
25/// duplicates kept.
26#[derive(Debug, Clone, Default, PartialEq, Eq)]
27pub struct PageRange(Vec<PageIndex>);
28
29impl PageRange {
30    /// Every page of a document of `count` pages, in order.
31    #[must_use]
32    pub fn all(count: u32) -> Self {
33        Self((0..count).map(PageIndex::from).collect())
34    }
35
36    /// A range naming exactly these zero-based indices.
37    #[must_use]
38    pub fn of(indices: impl IntoIterator<Item = impl Into<PageIndex>>) -> Self {
39        Self(indices.into_iter().map(Into::into).collect())
40    }
41
42    /// Parse `"1,3-5"` against a document of `count` pages.
43    ///
44    /// Numbers in the text are **one**-based; the indices returned are
45    /// zero-based.
46    ///
47    /// # Errors
48    ///
49    /// [`Error::BadPageRange`] for anything the grammar rejects — an illegal
50    /// character, a page number outside `1..=count`, a descending range, a
51    /// three-part entry, or an empty entry. One bad entry fails the whole
52    /// string.
53    ///
54    /// ```
55    /// use pdfrum_edit::PageRange;
56    ///
57    /// let range = PageRange::parse("1,3-5", 10)?;
58    /// assert_eq!(range.indices().iter().map(|p| p.get()).collect::<Vec<_>>(), [0, 2, 3, 4]);
59    /// // Order is preserved and duplicates are kept.
60    /// let dupes = PageRange::parse("2,1,1", 10)?;
61    /// assert_eq!(dupes.indices().iter().map(|p| p.get()).collect::<Vec<_>>(), [1, 0, 0]);
62    /// // One bad entry discards everything.
63    /// assert!(PageRange::parse("1,clams", 10).is_err());
64    /// # Ok::<(), pdfrum_edit::Error>(())
65    /// ```
66    pub fn parse(text: &str, count: u32) -> Result<Self, Error> {
67        // The only legal characters. Anything else fails the whole string —
68        // there is no "skip the junk" reading.
69        if !text
70            .bytes()
71            .all(|b| b == b' ' || b.is_ascii_digit() || b == b'-' || b == b',')
72        {
73            return Err(Error::BadPageRange);
74        }
75        // Spaces go everywhere, including from inside numbers. This is what
76        // makes "5  0" mean 50.
77        let stripped: String = text.chars().filter(|c| *c != ' ').collect();
78        if stripped.is_empty() {
79            return Ok(Self(Vec::new()));
80        }
81
82        let mut out = Vec::new();
83        for entry in stripped.split(',') {
84            let mut parts = entry.split('-');
85            let first = parts.next().unwrap_or_default();
86            let second = parts.next();
87            // Three parts is not a range, it is a malformed one.
88            if parts.next().is_some() {
89                return Err(Error::BadPageRange);
90            }
91
92            match second {
93                None => {
94                    // An empty entry reads as page 0, which is never valid —
95                    // so ",1", "1," and ",," all fail here.
96                    let n = number(first);
97                    if n == 0 || n > count {
98                        return Err(Error::BadPageRange);
99                    }
100                    out.push(PageIndex::new(n - 1));
101                }
102                Some(second) => {
103                    let (a, b) = (number(first), number(second));
104                    // `a` is never bounds-checked against `count` directly;
105                    // `a <= b <= count` bounds it transitively.
106                    if a == 0 || b == 0 || a > b || b > count {
107                        return Err(Error::BadPageRange);
108                    }
109                    out.extend(((a - 1)..b).map(PageIndex::from));
110                }
111            }
112        }
113        Ok(Self(out))
114    }
115
116    /// The zero-based indices, in the order named.
117    #[must_use]
118    pub fn indices(&self) -> &[PageIndex] {
119        &self.0
120    }
121
122    /// How many pages the range names, duplicates counted.
123    #[must_use]
124    pub fn len(&self) -> usize {
125        self.0.len()
126    }
127
128    /// Whether the range names no pages.
129    #[must_use]
130    pub fn is_empty(&self) -> bool {
131        self.0.is_empty()
132    }
133}
134
135/// A decimal number, saturating rather than overflowing, with an empty or
136/// unparseable string reading as zero — which every caller then rejects.
137fn number(text: &str) -> u32 {
138    let mut out: u32 = 0;
139    for b in text.bytes() {
140        let Some(digit) = (b as char).to_digit(10) else {
141            return 0;
142        };
143        out = out.saturating_mul(10).saturating_add(digit);
144    }
145    out
146}
147
148#[cfg(test)]
149mod tests {
150    use pdfrum_common::PageIndex;
151
152    use super::PageRange;
153
154    fn parse(text: &str, count: u32) -> Option<Vec<u32>> {
155        PageRange::parse(text, count)
156            .ok()
157            .map(|r| r.indices().iter().map(|p| p.get()).collect())
158    }
159
160    // cpdfsdk_helpers_unittest.cpp:51-93, the succeeding cases.
161    #[test]
162    fn simple_ranges_expand_inclusively() {
163        assert_eq!(parse("1", 10), Some(vec![0]));
164        assert_eq!(parse("1-1", 10), Some(vec![0]));
165        assert_eq!(parse("1-4", 10), Some(vec![0, 1, 2, 3]));
166        assert_eq!(parse("1,3-5", 10), Some(vec![0, 2, 3, 4]));
167        assert_eq!(parse("10", 10), Some(vec![9]));
168    }
169
170    // Space stripping, including inside numbers — the `// ???` behavior.
171    #[test]
172    fn spaces_are_stripped_from_inside_numbers() {
173        assert_eq!(parse("1- 4", 4), Some(vec![0, 1, 2, 3]));
174        assert_eq!(parse("1 -4", 4), Some(vec![0, 1, 2, 3]));
175        assert_eq!(parse(" 1 - 4 ", 4), Some(vec![0, 1, 2, 3]));
176        // Two digits separated by spaces are one number.
177        assert_eq!(parse("5  0, 1-2 ", 100), Some(vec![49, 0, 1]));
178    }
179
180    // The result is a sequence: duplicates and descending order are legal.
181    #[test]
182    fn duplicates_and_order_are_preserved() {
183        assert_eq!(parse("1-4,3-6", 10), Some(vec![0, 1, 2, 3, 2, 3, 4, 5]));
184        assert_eq!(parse("2,1", 10), Some(vec![1, 0]));
185        assert_eq!(parse("1,1,1,1", 10), Some(vec![0, 0, 0, 0]));
186    }
187
188    // The failing cases, all-or-nothing.
189    #[test]
190    fn the_grammar_rejects() {
191        for bad in [
192            "clams", // not a number at all
193            "0",     // page numbers are one-based
194            "42",    // past the end
195            "1-2-",  // three parts
196            "1-2-3",
197            ",1", // empty entry
198            "1,",
199            ",,",
200            "1-", // empty end
201            "-1", // empty start
202            "-,0,,,1-",
203            "1-2,,,,3-4",
204            "4-1", // descending
205            "1-5", // end past the count
206            "1;2", // illegal character
207            "1.2",
208            "a",
209        ] {
210            assert_eq!(parse(bad, 4), None, "{bad:?} must fail");
211        }
212    }
213
214    // One bad entry discards everything, including the good entries before it.
215    #[test]
216    fn one_bad_entry_discards_the_whole_string() {
217        assert_eq!(parse("1,2,clams", 10), None);
218        assert_eq!(parse("1,2,99", 10), None);
219    }
220
221    #[test]
222    fn an_empty_string_names_no_pages() {
223        assert_eq!(parse("", 10), Some(vec![]));
224        assert_eq!(parse("   ", 10), Some(vec![]));
225    }
226
227    // On a one-page document, only "1" and "1-1" work.
228    #[test]
229    fn a_one_page_document_accepts_only_page_one() {
230        assert_eq!(parse("1", 1), Some(vec![0]));
231        assert_eq!(parse("1-1", 1), Some(vec![0]));
232        assert_eq!(parse("2", 1), None);
233        assert_eq!(parse("1-2", 1), None);
234    }
235
236    #[test]
237    fn all_names_every_page_in_order() {
238        assert_eq!(
239            PageRange::all(3).indices(),
240            &[PageIndex::new(0), PageIndex::new(1), PageIndex::new(2)]
241        );
242        assert!(PageRange::all(0).is_empty());
243    }
244
245    #[test]
246    fn a_huge_number_saturates_rather_than_wrapping() {
247        assert_eq!(parse("99999999999999", 10), None);
248    }
249}