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}