use pdfrum_object::{Array, Dict, Name, Object, PdfString};
pub(crate) const MAX_WORD_LEN: usize = 255;
pub(crate) const MAX_STRING_LEN: usize = 32767;
pub(crate) const MAX_OBJECT_DEPTH: u32 = 64;
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Element<'a> {
Number(f32),
Name(Name),
Keyword(&'a [u8]),
Object(Object),
Eof,
}
#[derive(Debug, Clone)]
pub(crate) struct ContentLexer<'a> {
data: &'a [u8],
pos: usize,
}
fn is_ws(b: u8) -> bool {
matches!(b, 0x00 | 0x09 | 0x0A | 0x0C | 0x0D | 0x20)
}
fn is_delim(b: u8) -> bool {
matches!(
b,
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
)
}
fn is_numeric_char(b: u8) -> bool {
b.is_ascii_digit() || matches!(b, b'+' | b'-' | b'.')
}
fn word_to_number(word: &[u8]) -> f32 {
let has_dot = word.contains(&b'.');
let start = if has_dot { skip_leading_signs(word) } else { 0 };
let word = word.get(start..).unwrap_or_default();
if has_dot {
return parse_real(word);
}
parse_integer(word)
}
fn parse_integer(word: &[u8]) -> f32 {
let mut neg = false;
let mut value: i64 = 0;
let mut i = 0;
while let Some(&b) = word.get(i) {
match b {
b'-' if i == 0 => neg = true,
b'+' if i == 0 => {}
b'0'..=b'9' => value = value.saturating_mul(10).saturating_add(i64::from(b - b'0')),
_ => break,
}
i += 1;
}
#[expect(
clippy::cast_precision_loss,
reason = "matching the C++'s int-to-float widening exactly, including its loss"
)]
let widened = value as f32;
if neg { -widened } else { widened }
}
fn parse_real(word: &[u8]) -> f32 {
parse_real_fast(word).unwrap_or_else(|| parse_real_slow(word))
}
fn parse_real_slow(word: &[u8]) -> f32 {
let end = numeric_prefix(word);
let Some(prefix) = word.get(..end) else {
return 0.0;
};
let Ok(text) = std::str::from_utf8(prefix) else {
return 0.0;
};
text.parse::<f32>().unwrap_or(0.0)
}
const POW10: [f32; 11] = [1.0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10];
fn parse_real_fast(word: &[u8]) -> Option<f32> {
let mut i = 0;
let neg = matches!(word.first(), Some(b'-'));
if matches!(word.first(), Some(b'+' | b'-')) {
i = 1;
}
let (mut m, mut k, mut n, mut seen_dot) = (0u64, 0usize, 0usize, false);
while let Some(&b) = word.get(i) {
match b {
b'0'..=b'9' => {
m = m.saturating_mul(10).saturating_add(u64::from(b - b'0'));
n += 1;
k += usize::from(seen_dot);
i += 1;
}
b'.' if !seen_dot => {
seen_dot = true;
i += 1;
}
_ => break,
}
}
if matches!(word.get(i), Some(b'e' | b'E')) {
return None;
}
if n == 0 {
return Some(0.0);
}
if m >= (1 << 24) || k > 10 {
return None;
}
#[expect(clippy::cast_precision_loss, reason = "m < 2^24 is exact in f32")]
let value = m as f32 / *POW10.get(k)?;
Some(if neg { -value } else { value })
}
fn numeric_prefix(word: &[u8]) -> usize {
let mut i = 0;
if matches!(word.first(), Some(b'+' | b'-')) {
i = 1;
}
let mantissa_start = i;
let mut seen_dot = false;
while let Some(&b) = word.get(i) {
match b {
b'0'..=b'9' => i += 1,
b'.' if !seen_dot => {
seen_dot = true;
i += 1;
}
_ => break,
}
}
if i == mantissa_start || (seen_dot && i == mantissa_start + 1) {
return 0;
}
let mantissa_end = i;
if !matches!(word.get(i), Some(b'e' | b'E')) {
return mantissa_end;
}
let mut j = i + 1;
if matches!(word.get(j), Some(b'+' | b'-')) {
j += 1;
}
let digits_start = j;
while matches!(word.get(j), Some(b'0'..=b'9')) {
j += 1;
}
if j == digits_start { mantissa_end } else { j }
}
fn skip_leading_signs(word: &[u8]) -> usize {
let mut start = 0;
while matches!(word.get(start), Some(b' ' | b'+' | b'-')) {
start += 1;
}
if start > 0 && word.get(start - 1) == Some(&b'-') {
start -= 1;
}
start
}
impl<'a> ContentLexer<'a> {
pub(crate) fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
pub(crate) fn pos(&self) -> usize {
self.pos
}
pub(crate) fn seek(&mut self, pos: usize) {
self.pos = pos.min(self.data.len());
}
pub(crate) fn data(&self) -> &'a [u8] {
self.data
}
fn peek(&self) -> Option<u8> {
self.data.get(self.pos).copied()
}
fn skip_blanks(&mut self) {
while let Some(b) = self.peek() {
if is_ws(b) {
self.pos += 1;
} else if b == b'%' {
while let Some(c) = self.peek() {
self.pos += 1;
if c == b'\r' || c == b'\n' {
break;
}
}
} else {
return;
}
}
}
pub(crate) fn next_element(&mut self) -> Element<'a> {
self.skip_blanks();
let Some(first) = self.peek() else {
return Element::Eof;
};
if is_delim(first) && first != b'/' {
let obj = ObjectReader::new(self).read(false, false, 0);
return Element::Object(obj);
}
let start = self.pos;
let mut is_number = true;
let mut len = 0usize;
while let Some(b) = self.peek() {
if !is_numeric_char(b) {
is_number = false;
}
self.pos += 1;
len += 1;
match self.peek() {
Some(next) if is_ws(next) || is_delim(next) => break,
Some(_) => {}
None => break,
}
}
let word = self
.data
.get(start..start + len.min(MAX_WORD_LEN))
.unwrap_or(&[]);
if is_number && len > 0 {
return Element::Number(word_to_number(word));
}
if word.first() == Some(&b'/') {
return Element::Name(Name::decode(word.get(1..).unwrap_or(&[])));
}
match word {
b"true" => Element::Object(Object::Bool(true)),
b"false" => Element::Object(Object::Bool(false)),
b"null" => Element::Object(Object::Null),
_ if word.is_empty() => Element::Eof,
_ => Element::Keyword(word),
}
}
fn next_word(&mut self) -> (&'a [u8], bool) {
self.skip_blanks();
let data = self.data;
let Some(first) = self.peek() else {
return (&[], false);
};
let start = self.pos;
if is_delim(first) {
self.pos += 1;
match first {
b'/' => {
while let Some(b) = self.peek() {
if is_ws(b) || is_delim(b) {
break;
}
self.pos += 1;
}
let len = (self.pos - start).min(MAX_WORD_LEN + 1);
return (data.get(start..start + len).unwrap_or(&[]), false);
}
b'<' | b'>' => {
if self.peek() == Some(first) {
self.pos += 1;
}
return (data.get(start..self.pos).unwrap_or(&[]), false);
}
_ => return (data.get(start..self.pos).unwrap_or(&[]), false),
}
}
let mut is_number = true;
let mut len = 0usize;
while let Some(b) = self.peek() {
if is_ws(b) || is_delim(b) {
break;
}
if !is_numeric_char(b) {
is_number = false;
}
self.pos += 1;
len += 1;
}
let word = data
.get(start..start + len.min(MAX_WORD_LEN))
.unwrap_or(&[]);
(word, is_number && len > 0)
}
fn read_literal_string(&mut self) -> PdfString {
let mut out: Vec<u8> = Vec::new();
let mut level = 0i32;
let mut state = 0u8; let mut octal = 0u32;
let mut digits = 0u8;
let push = |out: &mut Vec<u8>, b: u8| {
if out.len() < MAX_STRING_LEN {
out.push(b);
}
};
while let Some(b) = self.peek() {
self.pos += 1;
match state {
0 => match b {
b'\\' => state = 1,
b'(' => {
level += 1;
push(&mut out, b);
}
b')' => {
if level == 0 {
return PdfString::literal(out);
}
level -= 1;
push(&mut out, b);
}
_ => push(&mut out, b),
},
1 => {
state = 0;
match b {
b'n' => push(&mut out, b'\n'),
b'r' => push(&mut out, b'\r'),
b't' => push(&mut out, b'\t'),
b'b' => push(&mut out, 0x08),
b'f' => push(&mut out, 0x0C),
b'0'..=b'7' => {
octal = u32::from(b - b'0');
digits = 1;
state = 2;
}
b'\r' => state = 4,
b'\n' => {}
_ => push(&mut out, b),
}
}
2 | 3 => {
if b.is_ascii_digit() && b < b'8' {
octal = octal * 8 + u32::from(b - b'0');
digits += 1;
if digits == 3 {
let byte = u8::try_from(octal & 0xFF).unwrap_or(0);
push(&mut out, byte);
state = 0;
} else {
state = 3;
}
} else {
let byte = u8::try_from(octal & 0xFF).unwrap_or(0);
push(&mut out, byte);
state = 0;
self.pos -= 1;
}
}
_ => {
state = 0;
if b != b'\n' {
self.pos -= 1;
}
}
}
}
PdfString::literal(out)
}
fn read_hex_string(&mut self) -> PdfString {
let mut out: Vec<u8> = Vec::new();
let mut nibble: Option<u8> = None;
while let Some(b) = self.peek() {
self.pos += 1;
if b == b'>' {
break;
}
let Some(v) = (b as char).to_digit(16) else {
continue;
};
#[expect(
clippy::cast_possible_truncation,
reason = "a hex digit is always below 16"
)]
let v = v as u8;
match nibble.take() {
None => nibble = Some(v),
Some(hi) => {
if out.len() < MAX_STRING_LEN {
out.push((hi << 4) | v);
}
}
}
}
if let Some(hi) = nibble
&& out.len() < MAX_STRING_LEN
{
out.push(hi << 4);
}
PdfString::hex(out)
}
}
struct ObjectReader<'r, 'a> {
lexer: &'r mut ContentLexer<'a>,
last_word: &'a [u8],
}
impl<'r, 'a> ObjectReader<'r, 'a> {
fn new(lexer: &'r mut ContentLexer<'a>) -> Self {
Self {
lexer,
last_word: &[],
}
}
fn scan_word(&mut self) -> (&'a [u8], bool) {
let (word, is_number) = self.lexer.next_word();
self.last_word = word;
(word, is_number)
}
fn read(&mut self, allow_nested_array: bool, in_array: bool, depth: u32) -> Object {
if depth > MAX_OBJECT_DEPTH {
return Object::Null;
}
let (word, is_number) = self.scan_word();
if word.is_empty() {
return Object::Null;
}
if is_number {
return match std::str::from_utf8(word)
.ok()
.and_then(|s| s.parse::<i64>().ok())
{
Some(i) => Object::Int(i),
None => Object::Real(word_to_number(word)),
};
}
match word.first().copied() {
Some(b'<') if word.len() > 1 => self.read_dict(in_array, depth),
Some(b'[') => {
if !allow_nested_array && in_array {
return Object::Null;
}
self.read_array(allow_nested_array, depth)
}
_ => self.read_leaf(word),
}
}
fn read_dict(&mut self, in_array: bool, depth: u32) -> Object {
let mut dict = Dict::new();
loop {
let (key_word, _) = self.scan_word();
if key_word.is_empty() {
return Object::Null;
}
if key_word.len() == 2 && key_word.first() == Some(&b'>') {
return Object::Dict(dict);
}
if key_word.first() != Some(&b'/') {
return Object::Null;
}
let key = Name::decode(key_word.get(1..).unwrap_or(&[]));
let value = self.read(true, in_array, depth + 1);
if matches!(value, Object::Null) {
return Object::Null;
}
dict.push(key, value);
}
}
fn read_array(&mut self, allow_nested_array: bool, depth: u32) -> Object {
let mut array = Array::new();
loop {
let element = self.read(allow_nested_array, true, depth + 1);
if !matches!(element, Object::Null) {
array.push(element);
continue;
}
if self.last_word.is_empty() || self.last_word.first() == Some(&b']') {
return Object::Array(array);
}
}
}
fn read_leaf(&mut self, word: &[u8]) -> Object {
match word.first().copied() {
Some(b'/') => Object::Name(Name::decode(word.get(1..).unwrap_or(&[]))),
Some(b'(') => Object::Str(self.lexer.read_literal_string()),
Some(b'<') => Object::Str(self.lexer.read_hex_string()),
_ => match word {
b"false" => Object::Bool(false),
b"true" => Object::Bool(true),
_ => Object::Null,
},
}
}
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unreadable_literal,
clippy::float_cmp,
clippy::indexing_slicing,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
reason = "test fixtures quote oracle vectors verbatim and compare exactly"
)]
use super::{ContentLexer, Element, MAX_STRING_LEN, MAX_WORD_LEN, word_to_number};
#[test]
fn the_real_fast_path_agrees_with_the_parse_bit_for_bit() {
let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let mut accepted = 0usize;
let mut check = |word: &[u8]| {
if let Some(fast) = super::parse_real_fast(word) {
accepted += 1;
let slow = super::parse_real_slow(word);
assert_eq!(
fast.to_bits(),
slow.to_bits(),
"{}: fast {fast} slow {slow}",
String::from_utf8_lossy(word)
);
}
};
for _ in 0..200_000 {
let r = next();
let mut word = Vec::new();
match r % 4 {
0 => word.push(b'-'),
1 => word.push(b'+'),
_ => {}
}
let int_digits = (r >> 8) % 10;
let frac_digits = (r >> 16) % 13;
let mut digits = next();
for _ in 0..int_digits {
word.push(b'0' + (digits % 10) as u8);
digits /= 10;
}
if (r >> 24) % 5 != 0 || int_digits == 0 {
word.push(b'.');
let mut digits = next();
for _ in 0..frac_digits {
word.push(b'0' + (digits % 10) as u8);
digits /= 10;
}
}
check(&word);
}
for word in [
&b"-0.0"[..],
b"0.",
b".5",
b"-.",
b".",
b"+.25",
b"1.2e3",
b"1.2e",
b"16777216.0",
b"16777215.5",
b"0.00000000001",
b"123.4567890123",
b"-007.50",
b"1.2.3",
b"1.5x",
b"9999999.9999999",
b"0.1",
b"3.14159",
] {
check(word);
}
assert!(
accepted > 50_000,
"the fast path accepted only {accepted} words"
);
}
use pdfrum_object::Object;
fn elements(src: &[u8]) -> Vec<Element<'_>> {
let mut lexer = ContentLexer::new(src);
let mut out = Vec::new();
loop {
match lexer.next_element() {
Element::Eof => return out,
e => out.push(e),
}
}
}
#[test]
fn numbers_names_and_keywords_split() {
let got = elements(b"1 -2.5 /Name Tj");
assert_eq!(got.len(), 4);
assert!(matches!(got[0], Element::Number(n) if (n - 1.0).abs() < 1e-6));
assert!(matches!(got[1], Element::Number(n) if (n + 2.5).abs() < 1e-6));
assert!(matches!(&got[2], Element::Name(n) if n.as_bytes() == b"Name"));
assert!(matches!(&got[3], Element::Keyword(k) if &**k == b"Tj"));
}
#[test]
fn a_repeated_sign_reads_differently_for_a_real_and_an_integer() {
assert!((word_to_number(b"--40.34") + 40.34).abs() < 1e-4);
assert!((word_to_number(b"---40.34") + 40.34).abs() < 1e-4);
assert!((word_to_number(b"++40.34") - 40.34).abs() < 1e-4);
assert!((word_to_number(b"-+40.34") - 40.34).abs() < 1e-4);
assert_eq!(word_to_number(b"--40"), 0.0);
assert!((word_to_number(b"-40") + 40.0).abs() < 1e-6);
assert!((word_to_number(b"-40.34") + 40.34).abs() < 1e-4);
}
#[test]
#[expect(
clippy::excessive_precision,
reason = "the expected values are the C++ test's own literals, spelled \
to more digits than an f32 holds; rounding them here would \
hide which value the vector actually names"
)]
fn a_real_reads_as_the_nearest_float_to_what_it_spells() {
assert_eq!(word_to_number(b"0.0"), 0.0);
assert_eq!(word_to_number(b"-0.0"), 0.0);
assert_eq!(word_to_number(b"0.25"), 0.25);
assert_eq!(word_to_number(b"+0.25"), 0.25);
assert_eq!(word_to_number(b"-0.25"), -0.25);
assert_eq!(word_to_number(b"100.0"), 100.0);
assert_eq!(word_to_number(b"-100.0000"), -100.0);
assert_eq!(word_to_number(b"38.895285"), 38.895_286_56);
assert_eq!(word_to_number(b"1.000000119"), 1.000_000_119);
assert_eq!(word_to_number(b"1.999999881"), 1.999_999_881);
assert_eq!(word_to_number(b"0.0025"), 0.0025);
assert_eq!(word_to_number(b".0025062656"), 0.002_506_265_6);
}
#[test]
fn an_exponent_is_part_of_the_number() {
assert_eq!(word_to_number(b"1.2e34"), 1.2e34);
assert_eq!(word_to_number(b"1.5e-3"), 1.5e-3);
assert_eq!(word_to_number(b"1.5E+2"), 150.0);
assert_eq!(word_to_number(b"1.2e"), 1.2);
assert_eq!(word_to_number(b"1.2ex"), 1.2);
assert_eq!(word_to_number(b"1.2e+"), 1.2);
}
#[test]
fn a_real_that_overflows_reads_as_infinity_and_nonsense_reads_as_zero() {
assert_eq!(
word_to_number(b"999999999999999999999999999999999999999.0"),
f32::INFINITY
);
assert_eq!(
word_to_number(b"-999999999999999999999999999999999999999.0"),
f32::NEG_INFINITY
);
assert_eq!(
word_to_number(b"340282300000000000000000000000000000000.0"),
3.402_823e38
);
assert_eq!(word_to_number(b"."), 0.0);
assert_eq!(word_to_number(b"-."), 0.0);
assert_eq!(word_to_number(b"inva.lid"), 0.0);
}
#[test]
fn comments_are_invisible() {
let got = elements(b"1 % this is 99 ignored\n 2 add");
assert_eq!(got.len(), 3);
assert!(matches!(got[1], Element::Number(n) if (n - 2.0).abs() < 1e-6));
}
#[test]
fn true_false_null_are_objects_not_keywords() {
let got = elements(b"true false null");
assert_eq!(
got,
vec![
Element::Object(Object::Bool(true)),
Element::Object(Object::Bool(false)),
Element::Object(Object::Null),
]
);
}
#[test]
fn words_truncate_at_255_bytes() {
let mut src = vec![b'/'];
src.extend(std::iter::repeat_n(b'a', 300));
let got = elements(&src);
let Some(Element::Name(name)) = got.first() else {
panic!("expected a name, got {got:?}");
};
assert_eq!(name.as_bytes().len(), MAX_WORD_LEN - 1);
}
#[test]
fn strings_truncate_at_32767_bytes() {
let mut src = vec![b'('];
src.extend(std::iter::repeat_n(b'x', 40_000));
src.push(b')');
let got = elements(&src);
let Some(Element::Object(Object::Str(s))) = got.first() else {
panic!("expected a string, got {got:?}");
};
assert_eq!(s.bytes.len(), MAX_STRING_LEN);
}
#[test]
fn hex_strings_pad_a_trailing_nibble() {
let got = elements(b"<1A2b>abcd");
let Some(Element::Object(Object::Str(s))) = got.first() else {
panic!("expected a string, got {got:?}");
};
assert_eq!(&*s.bytes, &[0x1a, 0x2b]);
let got = elements(b"<1A2b");
let Some(Element::Object(Object::Str(s))) = got.first() else {
panic!("expected a string, got {got:?}");
};
assert_eq!(&*s.bytes, &[0x1a, 0x2b]);
let got = elements(b"<1A2>asdf");
let Some(Element::Object(Object::Str(s))) = got.first() else {
panic!("expected a string, got {got:?}");
};
assert_eq!(&*s.bytes, &[0x1a, 0x20]);
let got = elements(b"<>");
let Some(Element::Object(Object::Str(s))) = got.first() else {
panic!("expected a string, got {got:?}");
};
assert!(s.bytes.is_empty());
}
#[test]
fn nested_array_at_operator_level_is_flattened() {
let got = elements(b"[1 [2] 3]");
let Some(Element::Object(Object::Array(a))) = got.first() else {
panic!("expected an array, got {got:?}");
};
assert_eq!(a.len(), 2);
assert_eq!(a.int_at(0), Some(1));
assert_eq!(a.int_at(1), Some(2));
assert!(got.len() > 1);
}
#[test]
fn dict_values_do_nest_arrays() {
let got = elements(b"<</K [1 [2] 3]>>");
let Some(Element::Object(Object::Dict(d))) = got.first() else {
panic!("expected a dict, got {got:?}");
};
let arr = d
.raw(&"K".into())
.and_then(Object::as_array)
.expect("array");
assert_eq!(arr.len(), 3);
assert!(matches!(arr.raw_at(1), Some(Object::Array(_))));
}
#[test]
fn a_non_name_dict_key_fails_the_whole_dict() {
let got = elements(b"<</A 1 2 3>>");
assert_eq!(got.first(), Some(&Element::Object(Object::Null)));
}
#[test]
fn a_closing_delimiter_alone_reads_as_null() {
for src in [&b"]"[..], b")", b"}", b"{"] {
let got = elements(src);
assert_eq!(
got.first(),
Some(&Element::Object(Object::Null)),
"for {src:?}"
);
}
}
#[test]
fn literal_string_escapes_and_nesting() {
let got = elements(br"(a\(b\)c\n\101\\)");
let Some(Element::Object(Object::Str(s))) = got.first() else {
panic!("expected a string, got {got:?}");
};
assert_eq!(&*s.bytes, b"a(b)c\nA\\");
let got = elements(b"(outer (inner) done)");
let Some(Element::Object(Object::Str(s))) = got.first() else {
panic!("expected a string, got {got:?}");
};
assert_eq!(&*s.bytes, b"outer (inner) done");
}
#[test]
fn line_continuations_are_dropped() {
let got = elements(b"(a\\\r\nb)");
let Some(Element::Object(Object::Str(s))) = got.first() else {
panic!("expected a string, got {got:?}");
};
assert_eq!(&*s.bytes, b"ab");
}
}