Skip to main content

pdfrum_text/
index.rs

1//! Mapping between character-list and search-facing text index spaces.
2//!
3//! Bridges [`CharIndex`] (positions in [`TextPage::chars`]) and [`TextIndex`]
4//! (positions in [`TextPage::search_text`]).
5//!
6//! [`TextPage::chars`]: crate::TextPage::chars
7//! [`TextPage::search_text`]: crate::TextPage::search_text
8
9// The character list and the text string are different sequences: a control
10// character, a character-code-zero placeholder or an unmapped code is in the
11// first and not the second, while normalization puts several entries in the
12// second for one in the first. So a caller holding a character index cannot
13// use it as a text offset, and vice versa.
14//
15// The bridge is a table of segments, each saying "text runs on from here for
16// this many characters". Building it is one pass; reading it is a walk.
17// **`--txt` uses none of this** — it reads the character list straight
18// through.
19
20use crate::charinfo::{CharBox, CharType};
21use std::fmt;
22
23/// A position in the character list ([`TextPage::chars`]) — the sequence a
24/// `--txt` dump emits.
25///
26/// Distinct from [`TextIndex`] on purpose: the two sequences disagree. A
27/// control character or an unmapped code is in this one and not the other,
28/// and one character here can become several there. The compiler is asked to
29/// notice instead of the caller.
30///
31/// [`TextPage::chars`]: crate::TextPage::chars
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
33pub struct CharIndex(usize);
34
35/// A position in the search-facing text ([`TextPage::search_text`]) — what a
36/// search matches and a selection copies.
37///
38/// Distinct from [`CharIndex`]; see there for why.
39///
40/// [`TextPage::search_text`]: crate::TextPage::search_text
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
42pub struct TextIndex(usize);
43
44macro_rules! index_newtype {
45    ($name:ident, $what:literal) => {
46        impl $name {
47            #[doc = concat!("The ", $what, " at this position.")]
48            #[must_use]
49            pub const fn new(index: usize) -> Self {
50                Self(index)
51            }
52
53            /// The position as a plain number.
54            #[must_use]
55            pub const fn get(self) -> usize {
56                self.0
57            }
58        }
59
60        impl From<usize> for $name {
61            fn from(index: usize) -> Self {
62                Self(index)
63            }
64        }
65
66        impl From<$name> for usize {
67            fn from(index: $name) -> Self {
68                index.0
69            }
70        }
71
72        impl fmt::Display for $name {
73            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74                self.0.fmt(f)
75            }
76        }
77    };
78}
79
80index_newtype!(CharIndex, "character-list position");
81index_newtype!(TextIndex, "text position");
82
83/// One run of characters that made it into the text.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct CharSegment {
86    /// The character index the run starts at.
87    pub index: u32,
88    /// How many characters it holds.
89    pub count: u32,
90}
91
92/// The map between the two index spaces of
93/// [`TextPage`](crate::TextPage).
94///
95/// A table of segments, not an index: it converts a character index into a
96/// text offset and back, so a caller never has to guess which of the two
97/// sequences a number counts in.
98#[derive(Debug, Clone, Default, PartialEq, Eq)]
99pub struct IndexMap {
100    segments: Vec<CharSegment>,
101}
102
103/// Builds the table from a character list.
104///
105/// A character counts when it is *generated* — every generated character is
106/// in both outputs — or when it is normal. Anything else breaks the run: the
107/// next counting character starts a new segment if the current one has
108/// anything in it, and otherwise just slides the current segment's start
109/// forward.
110#[must_use]
111pub(crate) fn build(chars: &[CharBox]) -> IndexMap {
112    let mut segments: Vec<CharSegment> = Vec::new();
113    if !chars.is_empty() {
114        segments.push(CharSegment { index: 0, count: 0 });
115    }
116    // True once the current segment holds at least one counting character.
117    let mut started = false;
118    for (position, info) in chars.iter().enumerate() {
119        let counts = info.char_type == CharType::Generated || info.is_normal();
120        let next = u32::try_from(position.saturating_add(1)).unwrap_or(u32::MAX);
121        if counts {
122            if let Some(last) = segments.last_mut() {
123                last.count = last.count.saturating_add(1);
124            }
125            started = true;
126        } else if started {
127            segments.push(CharSegment {
128                index: next,
129                count: 0,
130            });
131            started = false;
132        } else if let Some(last) = segments.last_mut() {
133            last.index = next;
134        }
135    }
136    IndexMap { segments }
137}
138
139impl IndexMap {
140    /// The segments, oldest first.
141    #[must_use]
142    pub fn segments(&self) -> &[CharSegment] {
143        &self.segments
144    }
145
146    /// How many characters the text holds.
147    #[must_use]
148    pub fn text_len(&self) -> usize {
149        self.segments
150            .iter()
151            .map(|segment| segment.count as usize)
152            .sum()
153    }
154
155    /// The character index a text offset names.
156    ///
157    /// ```
158    /// # use pdfrum_text::{IndexMap, TextIndex};
159    /// let map = IndexMap::default();
160    /// assert_eq!(map.char_index(TextIndex::new(0)), None);
161    /// ```
162    #[must_use]
163    pub fn char_index(&self, text_index: TextIndex) -> Option<CharIndex> {
164        let mut remaining = text_index.get();
165        for segment in &self.segments {
166            let count = segment.count as usize;
167            if remaining < count {
168                return Some(CharIndex::new(segment.index as usize + remaining));
169            }
170            remaining -= count;
171        }
172        None
173    }
174
175    /// The text offset a character index names, or `None` for a character the
176    /// text does not hold.
177    #[must_use]
178    pub fn text_index(&self, char_index: CharIndex) -> Option<TextIndex> {
179        let char_index = char_index.get();
180        let mut before = 0usize;
181        for segment in &self.segments {
182            let start = segment.index as usize;
183            let count = segment.count as usize;
184            if char_index < start {
185                return None;
186            }
187            if char_index < start + count {
188                return Some(TextIndex::new(before + (char_index - start)));
189            }
190            before += count;
191        }
192        None
193    }
194
195    /// The text offset a character index names, rounded **forward** to the
196    /// next character the text does hold.
197    ///
198    /// What a start bound wants: a request that begins on a stripped
199    /// character should begin at the next real one rather than failing.
200    #[must_use]
201    pub fn text_index_at_or_after(&self, char_index: CharIndex) -> Option<TextIndex> {
202        let char_index = char_index.get();
203        let mut before = 0usize;
204        for segment in &self.segments {
205            let start = segment.index as usize;
206            let count = segment.count as usize;
207            if count == 0 {
208                continue;
209            }
210            if char_index < start {
211                return Some(TextIndex::new(before));
212            }
213            if char_index < start + count {
214                return Some(TextIndex::new(before + (char_index - start)));
215            }
216            before += count;
217        }
218        None
219    }
220
221    /// The text offset **one past** the last text character at or before
222    /// `char_index`, which is what an end bound wants.
223    #[must_use]
224    pub fn text_index_end(&self, char_index: CharIndex) -> TextIndex {
225        let char_index = char_index.get();
226        let mut before = 0usize;
227        let mut end = 0usize;
228        for segment in &self.segments {
229            let start = segment.index as usize;
230            let count = segment.count as usize;
231            if count == 0 {
232                continue;
233            }
234            if char_index < start {
235                break;
236            }
237            end = if char_index < start + count {
238                before + (char_index - start) + 1
239            } else {
240                before + count
241            };
242            before += count;
243        }
244        TextIndex::new(end)
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    // Test fixtures quote the oracle's own vectors, compare floats exactly
251    // where the behaviour being pinned is exact, and index arrays whose
252    // length the fixture itself fixes.
253    #![allow(
254        clippy::float_cmp,
255        clippy::indexing_slicing,
256        clippy::unreadable_literal,
257        clippy::cast_precision_loss,
258        clippy::cast_possible_truncation,
259        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
260    )]
261
262    use super::*;
263    use kurbo::{Affine, Point, Rect};
264    use pdfrum_font::CharCode;
265
266    fn info(char_type: CharType, unicode: u32, code: Option<u32>) -> CharBox {
267        CharBox {
268            char_type,
269            unicode,
270            code: code.map(CharCode),
271            origin: Point::ZERO,
272            char_box: Rect::ZERO,
273            loose_char_box: Rect::ZERO,
274            matrix: Affine::IDENTITY,
275            object: None,
276            font_size: 1.0,
277            angle: 0.0,
278        }
279    }
280
281    fn normal(ch: char) -> CharBox {
282        info(CharType::Normal, u32::from(ch), Some(u32::from(ch)))
283    }
284
285    #[test]
286    fn a_run_with_nothing_stripped_is_one_segment() {
287        let chars: Vec<CharBox> = "hello".chars().map(normal).collect();
288        let index = build(&chars);
289        assert_eq!(index.segments(), [CharSegment { index: 0, count: 5 }]);
290        assert_eq!(index.text_len(), 5);
291        for at in 0..5 {
292            assert_eq!(
293                index.char_index(TextIndex::new(at)),
294                Some(CharIndex::new(at))
295            );
296            assert_eq!(
297                index.text_index(CharIndex::new(at)),
298                Some(TextIndex::new(at))
299            );
300        }
301        assert_eq!(index.char_index(TextIndex::new(5)), None);
302    }
303
304    #[test]
305    fn an_empty_page_has_no_segments() {
306        let index = build(&[]);
307        assert!(index.segments().is_empty());
308        assert_eq!(index.text_len(), 0);
309        assert_eq!(index.char_index(TextIndex::new(0)), None);
310        assert_eq!(index.text_index(CharIndex::new(0)), None);
311    }
312
313    #[test]
314    fn a_stripped_character_splits_the_segments_and_keeps_the_char_index() {
315        // The `control_characters.pdf` shape: two control characters after
316        // five letters, so the *character* index of what follows is two
317        // higher than its text offset.
318        let mut chars: Vec<CharBox> = "Hello".chars().map(normal).collect();
319        chars.push(info(CharType::Normal, 0x02, Some(2)));
320        chars.push(info(CharType::Normal, 0x03, Some(3)));
321        chars.extend("world".chars().map(normal));
322        let index = build(&chars);
323        assert_eq!(index.text_len(), 10);
324        // Text offset 5 is the 'w', which is character index 7.
325        assert_eq!(index.char_index(TextIndex::new(5)), Some(CharIndex::new(7)));
326        assert_eq!(index.text_index(CharIndex::new(7)), Some(TextIndex::new(5)));
327        // The two control characters are in no segment.
328        assert_eq!(index.text_index(CharIndex::new(5)), None);
329        assert_eq!(index.text_index(CharIndex::new(6)), None);
330    }
331
332    #[test]
333    fn leading_stripped_characters_slide_the_first_segment_forward() {
334        // Before any counting character has been seen, a stripped one moves
335        // the segment's start rather than closing it.
336        let mut chars = vec![info(CharType::Normal, 0x02, Some(2))];
337        chars.extend("ab".chars().map(normal));
338        let index = build(&chars);
339        assert_eq!(index.segments(), [CharSegment { index: 1, count: 2 }]);
340        assert_eq!(index.char_index(TextIndex::new(0)), Some(CharIndex::new(1)));
341    }
342
343    #[test]
344    fn a_generated_character_always_counts() {
345        // Even though a generated CRLF is not "normal" text, both outputs
346        // hold it, so it never breaks a segment.
347        let mut chars: Vec<CharBox> = "ab".chars().map(normal).collect();
348        chars.push(info(CharType::Generated, u32::from('\r'), None));
349        chars.push(info(CharType::Generated, u32::from('\n'), None));
350        chars.extend("cd".chars().map(normal));
351        let index = build(&chars);
352        assert_eq!(index.segments(), [CharSegment { index: 0, count: 6 }]);
353    }
354
355    #[test]
356    fn the_charcode_zero_placeholder_is_stripped() {
357        // 22 NULs then "hello", which is `bug_425244539.pdf`'s shape: the
358        // text is five characters and the first of them is character 22.
359        let mut chars: Vec<CharBox> =
360            std::iter::repeat_n(info(CharType::Normal, 0, Some(0)), 22).collect();
361        chars.extend("hello".chars().map(normal));
362        let index = build(&chars);
363        assert_eq!(index.text_len(), 5);
364        assert_eq!(
365            index.char_index(TextIndex::new(0)),
366            Some(CharIndex::new(22))
367        );
368        assert_eq!(
369            index.text_index(CharIndex::new(22)),
370            Some(TextIndex::new(0))
371        );
372    }
373
374    #[test]
375    fn the_forward_and_backward_bounds_skip_stripped_characters() {
376        let mut chars: Vec<CharBox> = "ab".chars().map(normal).collect();
377        chars.push(info(CharType::Normal, 0x02, Some(2)));
378        chars.extend("cd".chars().map(normal));
379        let index = build(&chars);
380        // Character 2 is stripped: forward lands on 'c' at text offset 2.
381        assert_eq!(
382            index.text_index_at_or_after(CharIndex::new(2)),
383            Some(TextIndex::new(2))
384        );
385        // And the end bound for character 2 stops after 'b'.
386        assert_eq!(index.text_index_end(CharIndex::new(2)), TextIndex::new(2));
387        assert_eq!(index.text_index_end(CharIndex::new(3)), TextIndex::new(3));
388        assert_eq!(index.text_index_end(CharIndex::new(99)), TextIndex::new(4));
389    }
390
391    #[test]
392    fn the_two_index_spaces_are_different_types() {
393        // The whole point of the newtypes: a number that counts in one
394        // sequence cannot be handed to a method that counts in the other.
395        // That is a compile-time claim, so what is asserted here is the round
396        // trip through `new`/`get` and the `From` conversions that make the
397        // wrapping cheap at a boundary.
398        assert_eq!(CharIndex::new(7).get(), 7);
399        assert_eq!(TextIndex::new(7).get(), 7);
400        assert_eq!(CharIndex::from(3usize), CharIndex::new(3));
401        assert_eq!(TextIndex::from(3usize), TextIndex::new(3));
402        assert_eq!(usize::from(CharIndex::new(3)), 3);
403        assert_eq!(usize::from(TextIndex::new(3)), 3);
404        // `Display` is the bare number, so a diagnostic reads like an index.
405        assert_eq!(CharIndex::new(41).to_string(), "41");
406        assert_eq!(TextIndex::new(41).to_string(), "41");
407        // `Ord` orders like the number it wraps, which is what a range wants.
408        assert!(CharIndex::new(1) < CharIndex::new(2));
409        assert!(TextIndex::new(1) < TextIndex::new(2));
410        // And `Default` is position zero, so `..` bounds have a floor.
411        assert_eq!(CharIndex::default(), CharIndex::new(0));
412        assert_eq!(TextIndex::default(), TextIndex::new(0));
413    }
414
415    #[test]
416    fn the_conversions_round_trip_over_a_known_segment_table() {
417        // `control_characters.pdf`'s shape again, walked in both directions
418        // over the whole table rather than at three sample points: every text
419        // offset names a character, and that character names it back.
420        let mut chars: Vec<CharBox> = "Hello".chars().map(normal).collect();
421        chars.push(info(CharType::Normal, 0x02, Some(2)));
422        chars.push(info(CharType::Normal, 0x03, Some(3)));
423        chars.extend("world".chars().map(normal));
424        let map = build(&chars);
425
426        for at in 0..map.text_len() {
427            let text = TextIndex::new(at);
428            let ch = map.char_index(text).expect("every text offset has a char");
429            assert_eq!(map.text_index(ch), Some(text), "round trip at {text}");
430            // And the forward bound agrees with the exact one on a character
431            // the text does hold.
432            assert_eq!(map.text_index_at_or_after(ch), Some(text));
433            // The end bound is one past it.
434            assert_eq!(map.text_index_end(ch), TextIndex::new(at + 1));
435        }
436        // Past the end in text space names no character at all.
437        assert_eq!(map.char_index(TextIndex::new(map.text_len())), None);
438
439        // The other direction over the *character* list: the two stripped
440        // characters are the only ones with no text offset of their own, and
441        // the forward bound rounds them up to the next real one.
442        let stripped: Vec<usize> = (0..chars.len())
443            .filter(|at| map.text_index(CharIndex::new(*at)).is_none())
444            .collect();
445        assert_eq!(stripped, [5, 6]);
446        assert_eq!(
447            map.text_index_at_or_after(CharIndex::new(5)),
448            Some(TextIndex::new(5))
449        );
450        assert_eq!(
451            map.text_index_at_or_after(CharIndex::new(6)),
452            Some(TextIndex::new(5))
453        );
454        // And their end bound stops after the last real character before them.
455        assert_eq!(map.text_index_end(CharIndex::new(5)), TextIndex::new(5));
456        assert_eq!(map.text_index_end(CharIndex::new(6)), TextIndex::new(5));
457    }
458}