Skip to main content

pdfrum_parser/
lexer.rs

1//! Byte classification and tokenization of PDF syntax (ISO 32000-1 §7.2).
2//!
3//! # The classifier is the file format
4//!
5//! Almost every syntactic decision a PDF reader makes reduces to "what class
6//! is this byte": whitespace separates tokens, delimiters end them and start
7//! composite objects, and the numeric class decides whether a word can be a
8//! number. The table here is not the one ISO 32000-1 §7.2.2 prints. Two
9//! bytes differ, and both differences are load-bearing:
10//!
11//! - `0x80` and `0xFF` count as **whitespace**. Real files separate tokens
12//!   with them, and a reader that treats them as name characters reads
13//!   different objects out of the same bytes.
14//! - `0x0B` (vertical tab) is **not** whitespace, though the specification
15//!   lists no such byte either way. A `0x0B` inside a name stays in the name.
16//!
17//! # Positions, not slices
18//!
19//! [`Lexer`] is a cursor: a byte slice plus an offset. It hands out tokens
20//! that borrow from those bytes and never copies except where an escape
21//! sequence forces it (a literal string with a `\n` in it cannot be a
22//! subslice of the file). Everything above this module addresses the file by
23//! offset, so seeking backwards to re-read a region is normal and cheap.
24//!
25//! # Truncation
26//!
27//! A word longer than [`Limits::max_word_len`] bytes keeps its first
28//! `max_word_len` bytes and drops the rest, while still consuming the whole
29//! run. A *name* keeps one byte fewer, because the slash it opens with
30//! occupies the first byte of the same budget. This is observable: two names
31//! agreeing on their first 255 payload bytes are the same name, while two
32//! keywords need 256 to collide. Files do not do this on purpose, but
33//! fuzzers and damaged files do, and the truncation is what decides whether
34//! their dictionaries have one key or two.
35
36use std::borrow::Cow;
37
38use pdfrum_common::{Limits, hex_digit};
39
40/// What a byte means to the tokenizer.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum CharClass {
43    /// Separates tokens and is otherwise ignored.
44    Whitespace,
45    /// Can appear in a number: `0`–`9`, `+`, `-`, `.`.
46    Numeric,
47    /// Ends the current token and begins a new one: `%()/<>[]{}`.
48    Delimiter,
49    /// Everything else — the body of keywords and names.
50    Regular,
51}
52
53/// The class of one byte.
54///
55/// The four groups, spelled out rather than tabulated, so the two bytes that
56/// deviate from ISO 32000-1 §7.2.2 sit in plain sight.
57///
58/// ```
59/// use pdfrum_parser::{CharClass, class_of};
60///
61/// assert_eq!(class_of(b' '), CharClass::Whitespace);
62/// // Two bytes the specification does not call whitespace, but files do.
63/// assert_eq!(class_of(0x80), CharClass::Whitespace);
64/// assert_eq!(class_of(0xFF), CharClass::Whitespace);
65/// // And one it arguably should: vertical tab is a regular character.
66/// assert_eq!(class_of(0x0B), CharClass::Regular);
67/// ```
68#[must_use]
69pub const fn class_of(byte: u8) -> CharClass {
70    match byte {
71        // NUL, TAB, LF, FF, CR and SPACE — plus the two high bytes files
72        // separate tokens with. Note 0x0B (vertical tab) is deliberately
73        // absent, and stays a regular character.
74        0x00 | 0x09 | 0x0A | 0x0C | 0x0D | 0x20 | 0x80 | 0xFF => CharClass::Whitespace,
75        b'0'..=b'9' | b'+' | b'-' | b'.' => CharClass::Numeric,
76        b'%' | b'(' | b')' | b'/' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' => CharClass::Delimiter,
77        _ => CharClass::Regular,
78    }
79}
80
81/// Whether a byte separates tokens.
82#[must_use]
83pub fn is_whitespace(byte: u8) -> bool {
84    class_of(byte) == CharClass::Whitespace
85}
86
87/// Whether a byte may appear in a number: `0`–`9`, `+`, `-`, `.`.
88#[must_use]
89pub fn is_numeric(byte: u8) -> bool {
90    class_of(byte) == CharClass::Numeric
91}
92
93/// Whether a byte ends a token and starts syntax of its own.
94#[must_use]
95pub fn is_delimiter(byte: u8) -> bool {
96    class_of(byte) == CharClass::Delimiter
97}
98
99/// Whether a byte ends a line, for the purposes of `stream` data and
100/// `%%EOF` scanning.
101#[must_use]
102pub fn is_line_ending(byte: u8) -> bool {
103    byte == b'\r' || byte == b'\n'
104}
105
106/// One syntactic token, borrowing from the file.
107///
108/// The tokenizer is deliberately shallow: it reports *what shape* a run of
109/// bytes has, never what it means. Whether `12` is a length, an object
110/// number, or an array element is the grammar's business, so a number arrives
111/// as [`Token::Number`] carrying its spelling and the value parse happens one
112/// layer up.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum Token<'a> {
115    /// A word whose every byte is in the numeric class. Spelling is kept
116    /// because `--37`, `1.2.3` and `+-.` all reach here, and only the value
117    /// parse decides what they are worth.
118    Number(&'a [u8]),
119    /// A name, without its leading slash and before escape decoding. An
120    /// empty slice is the valid empty name `/`.
121    Name(&'a [u8]),
122    /// A keyword or any other run of regular bytes: `obj`, `endstream`,
123    /// `true`, or junk.
124    Keyword(&'a [u8]),
125    /// A punctuation token: one delimiter, or the paired `<<` and `>>`.
126    Delim(Delim),
127    /// The file ended before another token began.
128    Eof,
129}
130
131impl<'a> Token<'a> {
132    /// The token's bytes as the tokenizer stored them, for the callers that
133    /// compare against literals. Punctuation answers with its spelling.
134    #[must_use]
135    pub fn bytes(&self) -> &'a [u8] {
136        match self {
137            Self::Number(b) | Self::Name(b) | Self::Keyword(b) => b,
138            Self::Delim(d) => d.as_bytes(),
139            Self::Eof => b"",
140        }
141    }
142
143    /// Whether this is a number-shaped word.
144    #[must_use]
145    pub fn is_number(&self) -> bool {
146        matches!(self, Self::Number(_))
147    }
148
149    /// Whether the file ended.
150    #[must_use]
151    pub fn is_eof(&self) -> bool {
152        matches!(self, Self::Eof)
153    }
154
155    /// Whether this is the given keyword.
156    #[must_use]
157    pub fn is_keyword(&self, word: &[u8]) -> bool {
158        matches!(self, Self::Keyword(b) if *b == word)
159    }
160}
161
162/// Punctuation the tokenizer recognizes.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum Delim {
165    /// `[`, opening an array.
166    ArrayOpen,
167    /// `]`, closing an array.
168    ArrayClose,
169    /// `<<`, opening a dictionary.
170    DictOpen,
171    /// `>>`, closing a dictionary.
172    DictClose,
173    /// `<`, opening a hexadecimal string.
174    HexOpen,
175    /// `>`, unpaired — syntactically meaningless on its own.
176    HexClose,
177    /// `(`, opening a literal string.
178    StringOpen,
179    /// `)`, unpaired.
180    StringClose,
181    /// `{`, which only PostScript calculator functions use.
182    BraceOpen,
183    /// `}`.
184    BraceClose,
185    /// `%`, reachable only when a caller tokenizes without skipping
186    /// comments first.
187    Percent,
188}
189
190impl Delim {
191    /// The delimiter's spelling.
192    #[must_use]
193    pub fn as_bytes(self) -> &'static [u8] {
194        match self {
195            Self::ArrayOpen => b"[",
196            Self::ArrayClose => b"]",
197            Self::DictOpen => b"<<",
198            Self::DictClose => b">>",
199            Self::HexOpen => b"<",
200            Self::HexClose => b">",
201            Self::StringOpen => b"(",
202            Self::StringClose => b")",
203            Self::BraceOpen => b"{",
204            Self::BraceClose => b"}",
205            Self::Percent => b"%",
206        }
207    }
208}
209
210/// A cursor over the bytes of a PDF file.
211///
212/// Offsets are relative to whatever slice the lexer was built over. The
213/// document layer passes the file *from its header onwards*, so a lexer
214/// position and a cross-reference offset mean the same thing.
215///
216/// ```
217/// use pdfrum_common::Limits;
218/// use pdfrum_parser::{Lexer, Token};
219///
220/// let limits = Limits::default();
221/// let mut lx = Lexer::new(b"12 0 obj % a comment\n<< /Type /Page >>");
222/// assert_eq!(lx.next_word(&limits), Token::Number(b"12"));
223/// assert_eq!(lx.next_word(&limits), Token::Number(b"0"));
224/// assert_eq!(lx.next_word(&limits), Token::Keyword(b"obj"));
225/// // Comments are invisible everywhere except inside strings.
226/// assert!(matches!(lx.next_word(&limits), Token::Delim(_)));
227/// assert_eq!(lx.next_word(&limits), Token::Name(b"Type"));
228/// ```
229#[derive(Debug, Clone)]
230pub struct Lexer<'a> {
231    bytes: &'a [u8],
232    pos: usize,
233}
234
235impl<'a> Lexer<'a> {
236    /// A lexer positioned at the start of `bytes`.
237    #[must_use]
238    pub fn new(bytes: &'a [u8]) -> Self {
239        Self { bytes, pos: 0 }
240    }
241
242    /// A lexer positioned at `pos`, clamped to the end of the input.
243    #[must_use]
244    pub fn at(bytes: &'a [u8], pos: usize) -> Self {
245        Self {
246            bytes,
247            pos: pos.min(bytes.len()),
248        }
249    }
250
251    /// The bytes being read.
252    #[must_use]
253    pub fn bytes(&self) -> &'a [u8] {
254        self.bytes
255    }
256
257    /// The current offset.
258    #[must_use]
259    pub fn pos(&self) -> usize {
260        self.pos
261    }
262
263    /// Move to `pos`, clamped to the end of the input.
264    pub fn seek(&mut self, pos: usize) {
265        self.pos = pos.min(self.bytes.len());
266    }
267
268    /// Whether the cursor is at or past the end.
269    #[must_use]
270    pub fn at_eof(&self) -> bool {
271        self.pos >= self.bytes.len()
272    }
273
274    /// The byte at the cursor, without advancing.
275    #[must_use]
276    pub fn peek_byte(&self) -> Option<u8> {
277        self.bytes.get(self.pos).copied()
278    }
279
280    /// Read one byte and advance.
281    fn read_byte(&mut self) -> Option<u8> {
282        let b = self.bytes.get(self.pos).copied()?;
283        self.pos += 1;
284        Some(b)
285    }
286
287    /// Step back one byte, if there is one.
288    fn unread(&mut self) {
289        self.pos = self.pos.saturating_sub(1);
290    }
291
292    /// Skip whitespace and comments, leaving the cursor on the first byte of
293    /// the next token (or at the end).
294    ///
295    /// A `%` runs to the next line ending, and the skipping repeats — so a
296    /// block of comment lines costs one call.
297    pub fn skip_to_word(&mut self) {
298        while let Some(b) = self.peek_byte() {
299            if is_whitespace(b) {
300                self.pos += 1;
301            } else if b == b'%' {
302                self.skip_comment();
303            } else {
304                return;
305            }
306        }
307    }
308
309    /// Consume a `%` comment up to but not including its line ending.
310    fn skip_comment(&mut self) {
311        while let Some(b) = self.peek_byte() {
312            if is_line_ending(b) {
313                return;
314            }
315            self.pos += 1;
316        }
317    }
318
319    /// Move past the next line ending, so the cursor sits on the first byte
320    /// of the following line.
321    ///
322    /// A `\r\n` pair counts as one ending. This is how stream data finds its
323    /// first byte after the `stream` keyword (ISO 32000-1 §7.3.8.1).
324    pub fn to_next_line(&mut self) {
325        while let Some(b) = self.read_byte() {
326            if b == b'\n' {
327                return;
328            }
329            if b == b'\r' {
330                if self.peek_byte() == Some(b'\n') {
331                    self.pos += 1;
332                }
333                return;
334            }
335        }
336    }
337
338    /// Consume one end-of-line marker if the cursor is on one, and report how
339    /// many bytes it took: two for `\r\n`, one for a lone `\r` or `\n`, zero
340    /// for anything else.
341    pub fn skip_eol_marker(&mut self) -> usize {
342        match self.peek_byte() {
343            Some(b'\r') => {
344                self.pos += 1;
345                if self.peek_byte() == Some(b'\n') {
346                    self.pos += 1;
347                    2
348                } else {
349                    1
350                }
351            }
352            Some(b'\n') => {
353                self.pos += 1;
354                1
355            }
356            _ => 0,
357        }
358    }
359
360    /// Read the next token, skipping whitespace and comments first.
361    ///
362    /// Words longer than `limits.max_word_len` are truncated in the returned
363    /// token but consumed whole, so the cursor always lands past the run.
364    pub fn next_word(&mut self, limits: &Limits) -> Token<'a> {
365        self.skip_to_word();
366        let Some(first) = self.read_byte() else {
367            return Token::Eof;
368        };
369
370        if is_delimiter(first) {
371            return self.delimiter_token(first, limits);
372        }
373
374        // A regular or numeric run, ending at whitespace or a delimiter —
375        // and the terminator is **pushed back either way**, whitespace
376        // included. That matters: `stream` is followed by the end-of-line
377        // that marks where its data begins, and a reader that swallowed the
378        // newline as a separator would start the payload one line late.
379        let start = self.pos - 1;
380        let mut all_numeric = is_numeric(first);
381        while let Some(b) = self.read_byte() {
382            if is_whitespace(b) || is_delimiter(b) {
383                self.unread();
384                break;
385            }
386            all_numeric &= is_numeric(b);
387        }
388        let word = truncate(self.bytes.get(start..self.pos).unwrap_or_default(), limits);
389        if all_numeric {
390            Token::Number(word)
391        } else {
392            Token::Keyword(word)
393        }
394    }
395
396    /// Tokenize a delimiter that has already been consumed.
397    fn delimiter_token(&mut self, first: u8, limits: &Limits) -> Token<'a> {
398        match first {
399            // A name runs while the bytes stay regular or numeric; both
400            // whitespace and any delimiter stop it, and the delimiter is
401            // pushed back.
402            b'/' => {
403                let start = self.pos;
404                while let Some(b) = self.peek_byte() {
405                    if matches!(class_of(b), CharClass::Regular | CharClass::Numeric) {
406                        self.pos += 1;
407                    } else {
408                        break;
409                    }
410                }
411                // A name's budget is one byte smaller than a keyword's,
412                // because the slash itself occupies the first byte of it.
413                // So two names sharing their first 255 payload bytes are the
414                // same name, while two keywords need 256 to collide.
415                let payload = self.bytes.get(start..self.pos).unwrap_or_default();
416                let budget = limits.max_word_len.saturating_sub(1);
417                Token::Name(payload.get(..budget).unwrap_or(payload))
418            }
419            b'<' => {
420                if self.peek_byte() == Some(b'<') {
421                    self.pos += 1;
422                    Token::Delim(Delim::DictOpen)
423                } else {
424                    Token::Delim(Delim::HexOpen)
425                }
426            }
427            b'>' => {
428                if self.peek_byte() == Some(b'>') {
429                    self.pos += 1;
430                    Token::Delim(Delim::DictClose)
431                } else {
432                    Token::Delim(Delim::HexClose)
433                }
434            }
435            b'[' => Token::Delim(Delim::ArrayOpen),
436            b']' => Token::Delim(Delim::ArrayClose),
437            b'(' => Token::Delim(Delim::StringOpen),
438            b')' => Token::Delim(Delim::StringClose),
439            b'{' => Token::Delim(Delim::BraceOpen),
440            b'}' => Token::Delim(Delim::BraceClose),
441            _ => Token::Delim(Delim::Percent),
442        }
443    }
444
445    /// Read the next token and restore the cursor, so a caller can decide
446    /// what to do without committing.
447    pub fn peek_word(&mut self, limits: &Limits) -> Token<'a> {
448        let saved = self.pos;
449        let token = self.next_word(limits);
450        self.pos = saved;
451        token
452    }
453
454    /// Read the body of a literal string, the `(` already consumed
455    /// (ISO 32000-1 §7.3.4.2).
456    ///
457    /// Nested parentheses are kept as content and only an unescaped `)` at
458    /// depth zero ends the string. An end of file ends it too, silently, with
459    /// whatever was read — the recovery scan depends on that, because it uses
460    /// this function to skip over string bodies that may well be truncated.
461    ///
462    /// The result borrows the file when no escape sequence forced a rewrite.
463    pub fn read_literal_string(&mut self) -> Cow<'a, [u8]> {
464        let start = self.pos;
465        let mut out: Option<Vec<u8>> = None;
466        let mut depth: u32 = 0;
467        // How many bytes from `start` are still a verbatim prefix of the
468        // output; once an escape appears, `out` takes over.
469        let mut verbatim_end = start;
470
471        while let Some(b) = self.read_byte() {
472            match b {
473                b'(' => {
474                    depth += 1;
475                    push(&mut out, verbatim_end, b);
476                    verbatim_end = self.pos;
477                }
478                b')' => {
479                    if depth == 0 {
480                        return finish(self.bytes, start, verbatim_end, out);
481                    }
482                    depth -= 1;
483                    push(&mut out, verbatim_end, b);
484                    verbatim_end = self.pos;
485                }
486                b'\\' => {
487                    // From here the output can no longer be a subslice.
488                    let buf = out.get_or_insert_with(|| {
489                        self.bytes
490                            .get(start..verbatim_end)
491                            .unwrap_or_default()
492                            .to_vec()
493                    });
494                    self.read_escape(buf);
495                    verbatim_end = self.pos;
496                }
497                _ => {
498                    push(&mut out, verbatim_end, b);
499                    verbatim_end = self.pos;
500                }
501            }
502        }
503        finish(self.bytes, start, verbatim_end, out)
504    }
505
506    /// Handle one backslash escape, the backslash already consumed.
507    fn read_escape(&mut self, out: &mut Vec<u8>) {
508        let Some(b) = self.read_byte() else { return };
509        match b {
510            b'n' => out.push(b'\n'),
511            b'r' => out.push(b'\r'),
512            b't' => out.push(b'\t'),
513            b'b' => out.push(0x08),
514            b'f' => out.push(0x0C),
515            // A backslash before a line ending is a line continuation: the
516            // ending vanishes and nothing is emitted.
517            b'\r' => {
518                if self.peek_byte() == Some(b'\n') {
519                    self.pos += 1;
520                }
521            }
522            b'\n' => {}
523            b'0'..=b'7' => {
524                // Up to three octal digits, wrapping into one byte, so `\777`
525                // is 0xFF rather than an error.
526                let mut value: u32 = u32::from(b - b'0');
527                for _ in 0..2 {
528                    match self.peek_byte() {
529                        Some(d @ b'0'..=b'7') => {
530                            self.pos += 1;
531                            value = value * 8 + u32::from(d - b'0');
532                        }
533                        _ => break,
534                    }
535                }
536                // The wrap is the behavior: `\777` is one byte, 0xFF.
537                out.push(u8::try_from(value & 0xFF).unwrap_or(0));
538            }
539            // Anything else stands for itself, which is how `\(`, `\)` and
540            // `\\` reach the output.
541            other => out.push(other),
542        }
543    }
544
545    /// Read the body of a hexadecimal string, the `<` already consumed
546    /// (ISO 32000-1 §7.3.4.3).
547    ///
548    /// Every byte that is neither a hex digit nor `>` is skipped without
549    /// comment — whitespace, NULs, letters, anything. A `>` or the end of
550    /// file ends the string, and a dangling half byte is padded with a zero
551    /// nibble, so `<1A2` reads as `1A 20`.
552    pub fn read_hex_string(&mut self) -> Vec<u8> {
553        let mut out = Vec::new();
554        let mut high: Option<u8> = None;
555        while let Some(b) = self.read_byte() {
556            if b == b'>' {
557                break;
558            }
559            let Some(nibble) = hex_digit(b) else { continue };
560            match high.take() {
561                None => high = Some(nibble),
562                Some(h) => out.push((h << 4) | nibble),
563            }
564        }
565        if let Some(h) = high {
566            out.push(h << 4);
567        }
568        out
569    }
570
571    /// Search backwards from the cursor for `word` as a whole word, within
572    /// `window` bytes, and leave the cursor on its first byte.
573    ///
574    /// "Whole word" means the neighbouring bytes are not regular or numeric; a
575    /// delimiter beside the word is an acceptable boundary. The cursor's own
576    /// byte is inside the search, so a match may end at `pos()` rather than
577    /// before it. This is how `startxref` is found in a file whose tail is
578    /// otherwise junk.
579    // That last byte matters: the caller starts nine bytes from the end of the
580    // file, so the position only reachable this way is a `startxref` followed
581    // by exactly eight bytes of offset and nothing else. A file truncated with
582    // no trailing end-of-line or `%%EOF` has precisely that shape, and it is
583    // the shape this search exists to rescue.
584    pub fn search_back(&mut self, word: &[u8], window: usize) -> bool {
585        if word.is_empty() || self.pos + 1 < word.len() {
586            return false;
587        }
588        let limit = self.pos.saturating_sub(window);
589        let mut candidate = (self.pos + 1).saturating_sub(word.len());
590        loop {
591            if self.bytes.get(candidate..candidate + word.len()) == Some(word)
592                && is_whole_word(
593                    self.bytes,
594                    candidate,
595                    word.len(),
596                    WordBoundary::WhitespaceOrDelimiter,
597                )
598            {
599                self.pos = candidate;
600                return true;
601            }
602            if candidate == 0 || candidate <= limit {
603                return false;
604            }
605            candidate -= 1;
606        }
607    }
608}
609
610/// Append one verbatim byte only once the output has been materialized.
611fn push(out: &mut Option<Vec<u8>>, _verbatim_end: usize, b: u8) {
612    if let Some(buf) = out {
613        buf.push(b);
614    }
615}
616
617/// Produce the string body, borrowing when nothing forced a copy.
618fn finish(bytes: &[u8], start: usize, verbatim_end: usize, out: Option<Vec<u8>>) -> Cow<'_, [u8]> {
619    match out {
620        Some(buf) => Cow::Owned(buf),
621        None => Cow::Borrowed(bytes.get(start..verbatim_end).unwrap_or_default()),
622    }
623}
624
625/// Keep at most `limits.max_word_len` bytes of a word.
626fn truncate<'a>(word: &'a [u8], limits: &Limits) -> &'a [u8] {
627    word.get(..limits.max_word_len).unwrap_or(word)
628}
629
630/// Which bytes may neighbour a match for it to stand alone as a word.
631///
632/// The two rules come from real files and each changes which bytes a damaged
633/// document yields, so the choice is the caller's and is named at every call
634/// site.
635#[derive(Debug, Clone, Copy, PartialEq, Eq)]
636pub enum WordBoundary {
637    /// Only whitespace. The stricter rule, used when scanning for
638    /// `endstream`: `>>endstream` does not count as a match.
639    WhitespaceOnly,
640    /// Whitespace or a delimiter — anything that is neither
641    /// [`CharClass::Regular`] nor [`CharClass::Numeric`]. The looser rule,
642    /// used for `startxref`, where the keyword may sit against `>>` or `]`.
643    WhitespaceOrDelimiter,
644}
645
646impl WordBoundary {
647    /// Whether `b` may sit beside a match under this rule.
648    #[must_use]
649    fn accepts(self, b: u8) -> bool {
650        match self {
651            Self::WhitespaceOnly => is_whitespace(b),
652            Self::WhitespaceOrDelimiter => {
653                !matches!(class_of(b), CharClass::Regular | CharClass::Numeric)
654            }
655        }
656    }
657}
658
659/// Whether the `len` bytes at `pos` stand alone as a word under `rule`.
660#[must_use]
661pub fn is_whole_word(bytes: &[u8], pos: usize, len: usize, rule: WordBoundary) -> bool {
662    let boundary = |b: u8| rule.accepts(b);
663    if pos > 0 && !bytes.get(pos - 1).copied().is_some_and(boundary) {
664        return false;
665    }
666    match bytes.get(pos + len) {
667        None => true,
668        Some(&b) => boundary(b),
669    }
670}
671
672/// Find `word` at or after `from`, as a whole word under `rule` (see
673/// [`is_whole_word`]). Returns the offset of its first byte.
674#[must_use]
675pub fn find_word(bytes: &[u8], word: &[u8], from: usize, rule: WordBoundary) -> Option<usize> {
676    if word.is_empty() || from > bytes.len() {
677        return None;
678    }
679    let last = bytes.len().checked_sub(word.len())?;
680    (from..=last).find(|&i| {
681        bytes.get(i..i + word.len()) == Some(word) && is_whole_word(bytes, i, word.len(), rule)
682    })
683}
684
685/// Parse an unsigned decimal the way the C library's conversion does, which
686/// is what every count and offset in a cross-reference table goes through.
687///
688/// Digits accumulate until a non-digit; overflow saturates at [`u32::MAX`]
689/// rather than wrapping; and a leading `-` negates in two's complement, so
690/// `-1` reads as `4294967295`. Object-stream offsets in the wild rely on
691/// exactly that, which is why this is not `str::parse`.
692#[must_use]
693pub fn atoui(word: &[u8]) -> u32 {
694    let (negative, digits) = match word.split_first() {
695        Some((b'-', rest)) => (true, rest),
696        Some((b'+', rest)) => (false, rest),
697        _ => (false, word),
698    };
699    let mut value: u32 = 0;
700    for &b in digits {
701        let Some(d) = (b as char).to_digit(10) else {
702            break;
703        };
704        value = match value.checked_mul(10).and_then(|v| v.checked_add(d)) {
705            Some(v) => v,
706            None => return u32::MAX,
707        };
708    }
709    if negative {
710        (!value).wrapping_add(1)
711    } else {
712        value
713    }
714}
715
716/// Parse a signed decimal into an `i64`, saturating rather than wrapping.
717///
718/// Cross-reference offsets and `startxref` targets come through here, so an
719/// absurd offset becomes an out-of-range one rather than a small valid one.
720#[must_use]
721pub fn atoi64(word: &[u8]) -> i64 {
722    let (negative, digits) = match word.split_first() {
723        Some((b'-', rest)) => (true, rest),
724        Some((b'+', rest)) => (false, rest),
725        _ => (false, word),
726    };
727    let mut value: i64 = 0;
728    for &b in digits {
729        let Some(d) = (b as char).to_digit(10) else {
730            break;
731        };
732        value = match value
733            .checked_mul(10)
734            .and_then(|v| v.checked_add(i64::from(d)))
735        {
736            Some(v) => v,
737            None => return if negative { i64::MIN } else { i64::MAX },
738        };
739    }
740    if negative { -value } else { value }
741}
742
743#[cfg(test)]
744mod tests {
745    use super::{
746        CharClass, Delim, Lexer, Token, WordBoundary, atoi64, atoui, class_of, find_word,
747        is_whole_word,
748    };
749    use pdfrum_common::Limits;
750
751    fn limits() -> Limits {
752        Limits::default()
753    }
754
755    #[test]
756    fn classifies_the_two_pdfium_quirks() {
757        assert_eq!(class_of(0x80), CharClass::Whitespace);
758        assert_eq!(class_of(0xFF), CharClass::Whitespace);
759        assert_eq!(class_of(0x0B), CharClass::Regular);
760        assert_eq!(class_of(b'.'), CharClass::Numeric);
761        assert_eq!(class_of(b'%'), CharClass::Delimiter);
762    }
763
764    #[test]
765    fn high_bytes_separate_words() {
766        let bytes = [b'a', 0x80, b'b', 0xFF, b'c'];
767        let mut lx = Lexer::new(&bytes);
768        assert_eq!(lx.next_word(&limits()), Token::Keyword(b"a"));
769        assert_eq!(lx.next_word(&limits()), Token::Keyword(b"b"));
770        assert_eq!(lx.next_word(&limits()), Token::Keyword(b"c"));
771        assert!(lx.next_word(&limits()).is_eof());
772    }
773
774    #[test]
775    fn vertical_tab_stays_inside_a_name() {
776        let bytes = [b'/', b'a', 0x0B, b'b', b' '];
777        let mut lx = Lexer::new(&bytes);
778        assert_eq!(lx.next_word(&limits()), Token::Name(&[b'a', 0x0B, b'b']));
779    }
780
781    #[test]
782    fn number_tokens_are_shape_not_value() {
783        let mut lx = Lexer::new(b"--37 1.2.3 +-. 12a");
784        assert_eq!(lx.next_word(&limits()), Token::Number(b"--37"));
785        assert_eq!(lx.next_word(&limits()), Token::Number(b"1.2.3"));
786        assert_eq!(lx.next_word(&limits()), Token::Number(b"+-."));
787        assert_eq!(lx.next_word(&limits()), Token::Keyword(b"12a"));
788    }
789
790    #[test]
791    fn delimiters_pair_and_push_back() {
792        let mut lx = Lexer::new(b"<</a[1]>>><");
793        assert_eq!(lx.next_word(&limits()), Token::Delim(Delim::DictOpen));
794        assert_eq!(lx.next_word(&limits()), Token::Name(b"a"));
795        assert_eq!(lx.next_word(&limits()), Token::Delim(Delim::ArrayOpen));
796        assert_eq!(lx.next_word(&limits()), Token::Number(b"1"));
797        assert_eq!(lx.next_word(&limits()), Token::Delim(Delim::ArrayClose));
798        assert_eq!(lx.next_word(&limits()), Token::Delim(Delim::DictClose));
799        assert_eq!(lx.next_word(&limits()), Token::Delim(Delim::HexClose));
800        assert_eq!(lx.next_word(&limits()), Token::Delim(Delim::HexOpen));
801    }
802
803    #[test]
804    fn a_bare_slash_is_the_empty_name() {
805        let mut lx = Lexer::new(b"/ /Name/Other");
806        assert_eq!(lx.next_word(&limits()), Token::Name(b""));
807        assert_eq!(lx.next_word(&limits()), Token::Name(b"Name"));
808        assert_eq!(lx.next_word(&limits()), Token::Name(b"Other"));
809    }
810
811    #[test]
812    fn comments_vanish() {
813        let mut lx = Lexer::new(b"% one\n%two\n  42");
814        assert_eq!(lx.next_word(&limits()), Token::Number(b"42"));
815    }
816
817    #[test]
818    fn two_long_names_collide_one_byte_sooner_than_keywords() {
819        // 255 shared payload bytes make two names equal; keywords need 256.
820        let name = |tail: u8| {
821            let mut v = vec![b'/'];
822            v.extend(std::iter::repeat_n(b'a', 255));
823            v.push(tail);
824            v.push(b' ');
825            v
826        };
827        let (x, y) = (name(b'x'), name(b'y'));
828        assert_eq!(
829            Lexer::new(&x).next_word(&limits()),
830            Lexer::new(&y).next_word(&limits())
831        );
832    }
833
834    #[test]
835    fn words_truncate_at_the_limit() {
836        let long = vec![b'a'; 300];
837        let mut source = long.clone();
838        source.push(b' ');
839        source.push(b'z');
840        let mut lx = Lexer::new(&source);
841        let token = lx.next_word(&limits());
842        assert_eq!(token.bytes().len(), 256);
843        // The whole run was still consumed.
844        assert_eq!(lx.next_word(&limits()), Token::Keyword(b"z"));
845    }
846
847    #[test]
848    fn names_truncate_one_byte_sooner_than_keywords() {
849        // The slash occupies the first byte of a name's budget, so its
850        // payload gets 255 where a keyword gets 256.
851        let mut source = vec![b'/'];
852        source.extend(std::iter::repeat_n(b'x', 300));
853        let mut lx = Lexer::new(&source);
854        assert_eq!(lx.next_word(&limits()).bytes().len(), 255);
855    }
856
857    #[test]
858    fn peek_is_position_neutral() {
859        let mut lx = Lexer::new(b"  hello world");
860        let before = lx.pos();
861        assert_eq!(lx.peek_word(&limits()), Token::Keyword(b"hello"));
862        assert_eq!(lx.pos(), before);
863        assert_eq!(lx.next_word(&limits()), Token::Keyword(b"hello"));
864    }
865
866    #[test]
867    fn literal_string_escapes() {
868        let cases: &[(&[u8], &[u8])] = &[
869            (b"abc)", b"abc"),
870            (b"a(b)c)", b"a(b)c"),
871            (b"\\n\\r\\t\\b\\f)", b"\n\r\t\x08\x0C"),
872            (b"\\101)", b"A"),
873            (b"\\777)", b"\xFF"),
874            (b"\\(\\)\\\\)", b"()\\"),
875            (b"a\\\nb)", b"ab"),
876            (b"a\\\r\nb)", b"ab"),
877            (b"a\\\rb)", b"ab"),
878            (b"\\q)", b"q"),
879            // End of file ends the string with what was read.
880            (b"abc", b"abc"),
881        ];
882        for (input, expected) in cases {
883            let mut lx = Lexer::new(input);
884            assert_eq!(&*lx.read_literal_string(), *expected, "input {input:?}");
885        }
886    }
887
888    #[test]
889    fn literal_string_borrows_when_it_can() {
890        let mut lx = Lexer::new(b"plain)");
891        assert!(matches!(
892            lx.read_literal_string(),
893            std::borrow::Cow::Borrowed(_)
894        ));
895    }
896
897    #[test]
898    fn hex_string_skips_everything_it_does_not_understand() {
899        // The assertion goldens from the C++ syntax-parser tests.
900        let cases: &[(&[u8], &[u8], usize)] = &[
901            (b"1A2b>abcd", b"\x1a\x2b", 5),
902            (b"1A2>abcd", b"\x1a\x20", 4),
903            (b"z12b>abcd", b"\x12\xb0", 5),
904            (b"*<&*#$^&@1>abcd", b"\x10", 11),
905            (b"\x00z12b>", b"\x12\xb0", 6),
906            (b"12&%^*b>", b"\x12\xb0", 8),
907            (b"1A2b", b"\x1a\x2b", 4),
908            (b"1A2", b"\x1a\x20", 3),
909            (b"", b"", 0),
910            (b">", b"", 1),
911        ];
912        for (input, expected, end) in cases {
913            let mut lx = Lexer::new(input);
914            assert_eq!(&lx.read_hex_string(), expected, "input {input:?}");
915            assert_eq!(lx.pos(), *end, "end position for {input:?}");
916        }
917    }
918
919    #[test]
920    fn to_next_line_treats_crlf_as_one() {
921        let mut lx = Lexer::new(b"abc\r\ndef");
922        lx.to_next_line();
923        assert_eq!(lx.pos(), 5);
924        let mut lx = Lexer::new(b"abc\rdef");
925        lx.to_next_line();
926        assert_eq!(lx.pos(), 4);
927        let mut lx = Lexer::new(b"abc\ndef");
928        lx.to_next_line();
929        assert_eq!(lx.pos(), 4);
930        // No line ending at all leaves the cursor at the end.
931        let mut lx = Lexer::new(b"abc");
932        lx.to_next_line();
933        assert_eq!(lx.pos(), 3);
934    }
935
936    #[test]
937    fn eol_markers_count_their_bytes() {
938        assert_eq!(Lexer::new(b"\r\nx").skip_eol_marker(), 2);
939        assert_eq!(Lexer::new(b"\rx").skip_eol_marker(), 1);
940        assert_eq!(Lexer::new(b"\nx").skip_eol_marker(), 1);
941        assert_eq!(Lexer::new(b"x").skip_eol_marker(), 0);
942    }
943
944    #[test]
945    fn whole_word_boundaries_differ_by_strictness() {
946        let bytes = b">>endstream ";
947        // Under the keyword rule a delimiter is not a boundary.
948        assert!(!is_whole_word(bytes, 2, 9, WordBoundary::WhitespaceOnly));
949        // Under the loose rule it is.
950        assert!(is_whole_word(
951            bytes,
952            2,
953            9,
954            WordBoundary::WhitespaceOrDelimiter
955        ));
956    }
957
958    #[test]
959    fn find_word_respects_the_keyword_rule() {
960        let bytes = b"x >>endstream y endstream z";
961        assert_eq!(
962            find_word(bytes, b"endstream", 0, WordBoundary::WhitespaceOnly),
963            Some(16)
964        );
965        assert_eq!(
966            find_word(bytes, b"endstream", 0, WordBoundary::WhitespaceOrDelimiter),
967            Some(4)
968        );
969        assert_eq!(
970            find_word(bytes, b"nothere", 0, WordBoundary::WhitespaceOnly),
971            None
972        );
973    }
974
975    #[test]
976    fn search_back_finds_the_last_occurrence() {
977        let bytes = b"startxref 1\nstartxref 2\n";
978        let mut lx = Lexer::at(bytes, bytes.len());
979        assert!(lx.search_back(b"startxref", 4096));
980        assert_eq!(lx.pos(), 12);
981    }
982
983    #[test]
984    fn search_back_includes_the_byte_under_the_cursor() {
985        // The word ends exactly *at* the cursor rather than before it. The
986        // reader starts nine bytes from the end of the file, so reaching this
987        // position means a `startxref` followed by a separator and a
988        // seven-digit offset and nothing else — a file truncated with no
989        // trailing end-of-line or `%%EOF`. No well-formed file lands here,
990        // which is why only this test holds the boundary.
991        let file = b"%PDF-1.7\nstartxref 1234567";
992        let start = 9;
993        let cursor = file.len() - 9;
994        assert_eq!(file.get(start..start + 9), Some(&b"startxref"[..]));
995        // The keyword's last byte *is* the cursor's byte.
996        assert_eq!(start + 8, cursor);
997
998        let mut lx = Lexer::at(file, cursor);
999        assert!(lx.search_back(b"startxref", 4096));
1000        assert_eq!(lx.pos(), start);
1001    }
1002
1003    #[test]
1004    fn search_back_declines_a_word_that_does_not_fit() {
1005        let mut lx = Lexer::at(b"xref", 1);
1006        assert!(!lx.search_back(b"startxref", 4096));
1007        assert_eq!(lx.pos(), 1);
1008        // A word exactly as long as the span up to and including the cursor.
1009        let mut lx = Lexer::at(b"abc", 2);
1010        assert!(lx.search_back(b"abc", 4096));
1011        assert_eq!(lx.pos(), 0);
1012    }
1013
1014    #[test]
1015    fn atoui_saturates_and_negates() {
1016        assert_eq!(atoui(b"0"), 0);
1017        assert_eq!(atoui(b"42"), 42);
1018        assert_eq!(atoui(b"4294967295"), u32::MAX);
1019        assert_eq!(atoui(b"99999999999"), u32::MAX);
1020        assert_eq!(atoui(b"-1"), u32::MAX);
1021        assert_eq!(atoui(b"-2"), u32::MAX - 1);
1022        assert_eq!(atoui(b"12a34"), 12);
1023        assert_eq!(atoui(b""), 0);
1024    }
1025
1026    #[test]
1027    fn atoi64_saturates() {
1028        assert_eq!(atoi64(b"-5"), -5);
1029        assert_eq!(atoi64(b"100940"), 100_940);
1030        assert_eq!(atoi64(b"999999999999999999999"), i64::MAX);
1031    }
1032
1033    #[test]
1034    fn never_panics_on_arbitrary_bytes() {
1035        for seed in 0u8..=255 {
1036            let bytes: Vec<u8> = (0..64u8)
1037                .map(|i| i.wrapping_mul(7).wrapping_add(seed))
1038                .collect();
1039            let mut lx = Lexer::new(&bytes);
1040            for _ in 0..200 {
1041                if lx.next_word(&limits()).is_eof() {
1042                    break;
1043                }
1044            }
1045        }
1046    }
1047}