use crate::color::Color;
fn is_whitespace(byte: u8) -> bool {
matches!(byte, b'\0' | b'\t' | b'\n' | 0x0C | b'\r' | b' ')
}
fn is_delimiter(byte: u8) -> bool {
matches!(
byte,
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
)
}
#[derive(Debug, Clone)]
pub struct Parser<'a> {
data: &'a [u8],
at: usize,
}
impl<'a> Parser<'a> {
#[must_use]
pub fn new(data: &'a [u8]) -> Parser<'a> {
Parser { data, at: 0 }
}
#[must_use]
pub fn position(&self) -> usize {
self.at
}
pub fn seek(&mut self, at: usize) {
self.at = at;
}
#[must_use]
pub fn next_word(&mut self) -> &'a [u8] {
let Some(first) = self.skip_spaces_and_comments() else {
return b"";
};
let start = self.at - 1;
if !is_delimiter(first) {
return self.scan_to_delimiter(start);
}
match first {
b'/' => self.scan_name(start),
b'<' => self.scan_angle_open(start),
b'>' => self.scan_angle_close(start),
b'(' => self.scan_parens(start),
_ => self.slice(start),
}
}
fn skip_spaces_and_comments(&mut self) -> Option<u8> {
loop {
let mut byte = *self.data.get(self.at)?;
self.at += 1;
while is_whitespace(byte) {
byte = *self.data.get(self.at)?;
self.at += 1;
}
if byte != b'%' {
return Some(byte);
}
loop {
let byte = *self.data.get(self.at)?;
self.at += 1;
if byte == b'\r' || byte == b'\n' {
break;
}
}
}
}
fn slice(&self, start: usize) -> &'a [u8] {
self.data.get(start..self.at).unwrap_or_default()
}
fn scan_to_delimiter(&mut self, start: usize) -> &'a [u8] {
while let Some(byte) = self.data.get(self.at) {
if is_delimiter(*byte) || is_whitespace(*byte) {
break;
}
self.at += 1;
}
self.slice(start)
}
fn scan_name(&mut self, start: usize) -> &'a [u8] {
while let Some(byte) = self.data.get(self.at) {
if is_delimiter(*byte) || is_whitespace(*byte) {
return self.slice(start);
}
self.at += 1;
}
b""
}
fn scan_angle_open(&mut self, start: usize) -> &'a [u8] {
let Some(byte) = self.data.get(self.at) else {
return self.slice(start);
};
self.at += 1;
if *byte == b'<' {
return self.slice(start);
}
let mut byte = *byte;
while self.at < self.data.len() && byte != b'>' {
byte = self.data.get(self.at).copied().unwrap_or(b'>');
self.at += 1;
}
self.slice(start)
}
fn scan_angle_close(&mut self, start: usize) -> &'a [u8] {
if self.data.get(self.at) == Some(&b'>') {
self.at += 1;
}
self.slice(start)
}
fn scan_parens(&mut self, start: usize) -> &'a [u8] {
let mut level = 1;
while self.at < self.data.len() && level > 0 {
let byte = self.data.get(self.at).copied().unwrap_or(b')');
self.at += 1;
match byte {
b'(' => level += 1,
b')' => level -= 1,
_ => {}
}
}
self.slice(start)
}
}
#[must_use]
pub fn find_tag_param_from_start(parser: &mut Parser<'_>, token: &[u8], params: usize) -> bool {
let slots = params + 1;
let mut ring = vec![0usize; slots];
let mut index = 0;
let mut filled = 0;
parser.seek(0);
loop {
if let Some(slot) = ring.get_mut(index) {
*slot = parser.position();
}
index = (index + 1) % slots;
filled = (filled + 1).min(slots);
let word = parser.next_word();
if word.is_empty() {
return false;
}
if word == token {
if filled < slots {
continue;
}
parser.seek(ring.get(index).copied().unwrap_or(0));
return true;
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct FontNameAndSize {
pub name: Vec<u8>,
pub size: f32,
}
#[must_use]
pub fn font(da: &[u8]) -> Option<FontNameAndSize> {
if da.is_empty() {
return None;
}
let mut parser = Parser::new(da);
if !find_tag_param_from_start(&mut parser, b"Tf", 2) {
return Some(FontNameAndSize::default());
}
let name = parser.next_word();
let size = parser.next_word();
Some(FontNameAndSize {
name: pdfrum_object::name_decode(name.get(1..).unwrap_or_default()),
size: parse_float(size),
})
}
#[must_use]
pub fn color(da: &[u8]) -> Option<Color> {
if da.is_empty() {
return None;
}
let mut parser = Parser::new(da);
if find_tag_param_from_start(&mut parser, b"g", 1) {
return Some(Color::Gray(parse_float(parser.next_word())));
}
if find_tag_param_from_start(&mut parser, b"rg", 3) {
let (r, g, b) = (
parse_float(parser.next_word()),
parse_float(parser.next_word()),
parse_float(parser.next_word()),
);
return Some(Color::Rgb(r, g, b));
}
if find_tag_param_from_start(&mut parser, b"k", 4) {
let (c, m, y, k) = (
parse_float(parser.next_word()),
parse_float(parser.next_word()),
parse_float(parser.next_word()),
parse_float(parser.next_word()),
);
return Some(Color::Cmyk(c, m, y, k));
}
None
}
fn parse_float(word: &[u8]) -> f32 {
let text = String::from_utf8_lossy(word);
let end = text
.char_indices()
.find(|(index, c)| {
!(c.is_ascii_digit() || (*index == 0 && (*c == '-' || *c == '+')) || *c == '.')
})
.map_or(text.len(), |(index, _)| index);
text.get(..end)
.and_then(|prefix| prefix.parse().ok())
.unwrap_or(0.0)
}
#[cfg(test)]
mod tests {
use super::{Parser, color, find_tag_param_from_start, font};
use crate::color::Color;
fn find(data: &str, token: &str, params: usize) -> (bool, usize) {
let mut parser = Parser::new(data.as_bytes());
let found = find_tag_param_from_start(&mut parser, token.as_bytes(), params);
(found, parser.position())
}
#[test]
fn the_search_reports_where_it_stopped_as_well_as_whether_it_found() {
assert_eq!(find("", "Tj", 1), (false, 0));
assert_eq!(find("", "", 1), (false, 0));
assert_eq!(find(" T j", "", 1), (false, 5));
assert_eq!(find("Tj", "Tj", 1), (false, 2));
assert_eq!(find("(Tj", "Tj", 1), (false, 3));
assert_eq!(find("\r12\t34 56 78Tj", "Tj", 1), (false, 15));
}
#[test]
fn a_match_rewinds_exactly_as_many_words_as_asked_for() {
assert_eq!(find("\r\0abd Tj", "Tj", 1), (true, 0));
assert_eq!(find("12 4 Tj 3 46 Tj", "Tj", 1), (true, 2));
assert_eq!(find("er^ 2 (34) (5667) Tj", "Tj", 2), (true, 5));
assert_eq!(find("<344> (232)\t343.4\n12 45 Tj", "Tj", 3), (true, 11));
assert_eq!(find("1 2 3 4 5 6 7 8 cm", "cm", 6), (true, 3));
}
#[test]
fn an_empty_string_and_a_string_without_a_font_answer_differently() {
assert_eq!(font(b""), None);
let no_tf = font(b"0 g").expect("not empty");
assert!(no_tf.name.is_empty());
assert!(no_tf.size.abs() < f32::EPSILON);
}
#[test]
fn the_font_operands_read_in_order() {
let got = font(b"0 0 0 rg /Helv 12 Tf").expect("has a font");
assert_eq!(got.name, b"Helv");
assert!((got.size - 12.0).abs() < f32::EPSILON);
}
#[test]
fn a_negative_size_survives() {
let got = font(b"0 0 0 rg /F1 -12 Tf").expect("has a font");
assert!((got.size + 12.0).abs() < f32::EPSILON);
}
#[test]
fn a_name_operand_loses_its_first_character_whether_or_not_it_is_a_slash() {
let slashless = font(b"Helv 12 Tf").expect("has a font");
assert_eq!(slashless.name, b"elv");
}
#[test]
fn a_hash_escape_in_the_name_is_decoded() {
let got = font(b"/A#20B 8 Tf").expect("has a font");
assert_eq!(got.name, b"A B");
}
#[test]
fn the_first_colour_operator_wins_wherever_it_sits() {
assert_eq!(color(b"1 0 0 rg 0 g"), Some(Color::Gray(0.0)));
assert_eq!(color(b"0 g 1 0 0 rg"), Some(Color::Gray(0.0)));
assert_eq!(color(b"1 0 0 rg"), Some(Color::Rgb(1.0, 0.0, 0.0)));
assert_eq!(
color(b"0 .25 .5 1 k"),
Some(Color::Cmyk(0.0, 0.25, 0.5, 1.0))
);
assert_eq!(color(b"/Helv 12 Tf"), None);
assert_eq!(color(b""), None);
}
#[test]
fn the_tokenizer_treats_parentheses_as_nesting_without_escapes() {
let mut parser = Parser::new(b"(a(b)c) next");
assert_eq!(parser.next_word(), b"(a(b)c)");
assert_eq!(parser.next_word(), b"next");
let mut parser = Parser::new(br"(a\) b");
assert_eq!(parser.next_word(), br"(a\)");
}
#[test]
fn a_comment_runs_to_the_end_of_its_line() {
let mut parser = Parser::new(b"% skipped\n/Helv 12 Tf");
assert_eq!(parser.next_word(), b"/Helv");
assert_eq!(parser.next_word(), b"12");
assert_eq!(parser.next_word(), b"Tf");
assert_eq!(parser.next_word(), b"");
}
#[test]
fn angle_brackets_tokenize_as_hex_strings_and_dictionary_marks() {
let mut parser = Parser::new(b"<< <41> >>");
assert_eq!(parser.next_word(), b"<<");
assert_eq!(parser.next_word(), b"<41>");
assert_eq!(parser.next_word(), b">>");
}
}