# -*- encoding: utf-8; frozen_string_literal: true -*-
#
#--
# This file is part of HexaPDF.
#
# HexaPDF - A Versatile PDF Creation and Manipulation Library For Ruby
# Copyright (C) 2014-2022 Thomas Leitner
#
# HexaPDF is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License version 3 as
# published by the Free Software Foundation with the addition of the
# following permission added to Section 15 as permitted in Section 7(a):
# FOR ANY PART OF THE COVERED WORK IN WHICH THE COPYRIGHT IS OWNED BY
# THOMAS LEITNER, THOMAS LEITNER DISCLAIMS THE WARRANTY OF NON
# INFRINGEMENT OF THIRD PARTY RIGHTS.
#
# HexaPDF is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public
# License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with HexaPDF. If not, see .
#
# The interactive user interfaces in modified source and object code
# versions of HexaPDF must display Appropriate Legal Notices, as required
# under Section 5 of the GNU Affero General Public License version 3.
#
# In accordance with Section 7(b) of the GNU Affero General Public
# License, a covered work must retain the producer line in every PDF that
# is created or manipulated using HexaPDF.
#
# If the GNU Affero General Public License doesn't fit your need,
# commercial licenses are available at .
#++
require 'strscan'
require 'hexapdf/error'
require 'hexapdf/reference'
module HexaPDF
# Tokenizes the content of an IO object following the PDF rules.
#
# See: PDF1.7 s7.2
class Tokenizer
# Represents a keyword in a PDF file.
class Token < String; end
TOKEN_DICT_START = Token.new('<<'.b) # :nodoc:
TOKEN_DICT_END = Token.new('>>'.b) # :nodoc:
TOKEN_ARRAY_START = Token.new('['.b) # :nodoc:
TOKEN_ARRAY_END = Token.new(']'.b) # :nodoc:
# This object is returned when there are no more tokens to read.
NO_MORE_TOKENS = ::Object.new
def NO_MORE_TOKENS.to_s
"EOS - no more tokens"
end
# Characters defined as whitespace.
#
# See: PDF1.7 s7.2.2
WHITESPACE = " \n\r\0\t\f"
# Characters defined as delimiters.
#
# See: PDF1.7 s7.2.2
DELIMITER = "()<>{}/[]%"
WHITESPACE_MULTI_RE = /[#{WHITESPACE}]+/ # :nodoc:
WHITESPACE_OR_DELIMITER_RE = /(?=[#{Regexp.escape(WHITESPACE + DELIMITER)}])/ # :nodoc:
# The IO object from the tokens are read.
attr_reader :io
# Creates a new tokenizer for the given IO stream.
#
# If +on_correctable_error+ is set to an object responding to +call(msg, pos)+, errors for
# correctable situations are only raised if the return value of calling the object is +true+.
def initialize(io, on_correctable_error: nil)
@io = io
@ss = StringScanner.new(''.b)
@original_pos = -1
@on_correctable_error = on_correctable_error || proc { false }
self.pos = 0
end
# Returns the current position of the tokenizer inside in the IO object.
#
# Note that this position might be different from +io.pos+ since the latter could have been
# changed somewhere else.
def pos
@original_pos + @ss.pos
end
# Sets the position at which the next token should be read.
#
# Note that this does **not** set +io.pos+ directly (at the moment of invocation)!
def pos=(pos)
if pos >= @original_pos && pos <= @original_pos + @ss.string.size
@ss.pos = pos - @original_pos
else
@original_pos = pos
@next_read_pos = pos
@ss.string.clear
@ss.reset
end
end
# Returns a single token read from the current position and advances the scan pointer.
#
# Comments and a run of whitespace characters are ignored. The value +NO_MORE_TOKENS+ is
# returned if there are no more tokens available.
def next_token
prepare_string_scanner(20)
prepare_string_scanner(20) while @ss.skip(WHITESPACE_MULTI_RE)
byte = @ss.string.getbyte(@ss.pos) || -1
if (48 <= byte && byte <= 57) || byte == 45 || byte == 43 || byte == 46 # 0..9 - + .
parse_number
elsif byte == 47 # /
parse_name
elsif byte == 40 # (
parse_literal_string
elsif byte == 60 # <
if @ss.string.getbyte(@ss.pos + 1) == 60
@ss.pos += 2
TOKEN_DICT_START
else
parse_hex_string
end
elsif byte == 62 # >
unless @ss.string.getbyte(@ss.pos + 1) == 62
raise HexaPDF::MalformedPDFError.new("Delimiter '>' found at invalid position", pos: pos)
end
@ss.pos += 2
TOKEN_DICT_END
elsif byte == 91 # [
@ss.pos += 1
TOKEN_ARRAY_START
elsif byte == 93 # ]
@ss.pos += 1
TOKEN_ARRAY_END
elsif byte == 123 || byte == 125 # { }
Token.new(@ss.get_byte)
elsif byte == 37 # %
until @ss.skip_until(/(?=[\r\n])/)
return NO_MORE_TOKENS unless prepare_string_scanner
end
next_token
elsif byte == -1 # we reached the end of the file
NO_MORE_TOKENS
else # everything else consisting of regular characters
parse_keyword
end
end
# Returns the next token but does not advance the scan pointer.
def peek_token
pos = self.pos
tok = next_token
self.pos = pos
tok
end
# Returns the PDF object at the current position. This is different from #next_token because
# references, arrays and dictionaries consist of multiple tokens.
#
# If the +allow_end_array_token+ argument is +true+, the ']' token is permitted to facilitate
# the use of this method during array parsing.
#
# See: PDF1.7 s7.3
def next_object(allow_end_array_token: false, allow_keyword: false)
token = next_token
if token.kind_of?(Token)
case token
when TOKEN_DICT_START
token = parse_dictionary
when TOKEN_ARRAY_START
token = parse_array
when TOKEN_ARRAY_END
unless allow_end_array_token
raise HexaPDF::MalformedPDFError.new("Found invalid end array token ']'", pos: pos)
end
else
unless allow_keyword
maybe_raise("Invalid object, got token #{token}", force: token !~ /^-?(nan|inf)$/i)
token = 0
end
end
end
token
end
# Returns a single integer or keyword token read from the current position and advances the scan
# pointer. If the current position doesn't contain such a token, +nil+ is returned without
# advancing the scan pointer. The value +NO_MORE_TOKENS+ is returned if there are no more tokens
# available.
#
# Initial runs of whitespace characters are ignored.
#
# Note: This is a special method meant for use with reconstructing the cross-reference table!
def next_integer_or_keyword
skip_whitespace
byte = @ss.string.getbyte(@ss.pos) || -1
if 48 <= byte && byte <= 57
parse_number
elsif (97 <= byte && byte <= 122) || (65 <= byte && byte <= 90)
parse_keyword
elsif byte == -1 # we reached the end of the file
NO_MORE_TOKENS
else
nil
end
end
# Reads the byte (an integer) at the current position and advances the scan pointer.
def next_byte
prepare_string_scanner(1)
@ss.pos += 1
@ss.string.getbyte(@ss.pos - 1)
end
# Reads the cross-reference subsection entry at the current position and advances the scan
# pointer.
#
# If a problem is detected, yields to caller where the argument +recoverable+ is truthy if the
# problem is recoverable.
#
# See: PDF1.7 7.5.4
def next_xref_entry #:yield: recoverable
prepare_string_scanner(20)
if !@ss.skip(/(\d{10}) (\d{5}) ([nf])(?: \r| \n|\r\n|(\r\r|\r|\n))/) || @ss[4]
yield(@ss[4])
end
[@ss[1].to_i, @ss[2].to_i, @ss[3]]
end
# Skips all whitespace at the current position.
#
# See: PDF1.7 s7.2.2
def skip_whitespace
prepare_string_scanner
prepare_string_scanner while @ss.skip(WHITESPACE_MULTI_RE)
end
# Utility method for scanning until the given regular expression matches.
#
# If the end of the file is reached in the process, +nil+ is returned. Otherwise the matched
# string is returned.
def scan_until(re)
until (data = @ss.scan_until(re))
return nil unless prepare_string_scanner
end
data
end
private
TOKEN_CACHE = Hash.new {|h, k| h[k] = Token.new(k) } # :nodoc:
TOKEN_CACHE['true'] = true
TOKEN_CACHE['false'] = false
TOKEN_CACHE['null'] = nil
# Parses the keyword at the current position.
#
# See: PDF1.7 s7.2
def parse_keyword
str = scan_until(WHITESPACE_OR_DELIMITER_RE) || @ss.scan(/.*/)
TOKEN_CACHE[str]
end
REFERENCE_RE = /[#{WHITESPACE}]+([+]?\d+)[#{WHITESPACE}]+R#{WHITESPACE_OR_DELIMITER_RE}/ # :nodoc:
# Parses the number (integer or real) at the current position.
#
# See: PDF1.7 s7.3.3
def parse_number
val = scan_until(WHITESPACE_OR_DELIMITER_RE) || @ss.scan(/.*/)
if val.match?(/\A[+-]?\d++(?!\.)\z/)
tmp = val.to_i
# Handle object references, see PDF1.7 s7.3.10
prepare_string_scanner(10)
if @ss.scan(REFERENCE_RE)
tmp = if tmp > 0
Reference.new(tmp, @ss[1].to_i)
else
maybe_raise("Invalid indirect object reference (#{tmp},#{@ss[1].to_i})")
nil
end
end
tmp
elsif val.match?(/\A[+-]?(?:\d+\.\d*|\.\d+)\z/)
val << '0' if val.getbyte(-1) == 46 # dot '.'
Float(val)
else
TOKEN_CACHE[val] # val is keyword
end
end
LITERAL_STRING_ESCAPE_MAP = { #:nodoc:
'n' => "\n",
'r' => "\r",
't' => "\t",
'b' => "\b",
'f' => "\f",
'(' => "(",
')' => ")",
'\\' => "\\",
}.freeze
# Parses the literal string at the current position.
#
# See: PDF1.7 s7.3.4.2
def parse_literal_string
@ss.pos += 1
str = "".b
parentheses = 1
while parentheses != 0
data = scan_until(/[()\\\r]/)
unless data
raise HexaPDF::MalformedPDFError.new("Unclosed literal string found", pos: pos)
end
str << data
prepare_string_scanner if @ss.eos?
case @ss.string.getbyte(@ss.pos - 1)
when 41 then parentheses -= 1 # )
when 40 then parentheses += 1 # (
when 13 # \r
str[-1] = "\n"
@ss.pos += 1 if @ss.peek(1) == "\n"
when 92 # \\
str.chop!
byte = @ss.get_byte
if (data = LITERAL_STRING_ESCAPE_MAP[byte])
str << data
elsif byte == "\r" || byte == "\n"
@ss.pos += 1 if byte == "\r" && @ss.peek(1) == "\n"
elsif byte >= '0' && byte <= '7'
byte += @ss.scan(/[0-7]{0,2}/)
str << byte.oct.chr
else
str << byte
end
end
end
str.chop! # remove last parsed closing parenthesis
str
end
# Parses the hex string at the current position.
#
# See: PDF1.7 s7.3.4.3
def parse_hex_string
@ss.pos += 1
data = scan_until(/(?=>)/)
unless data
raise HexaPDF::MalformedPDFError.new("Unclosed hex string found", pos: pos)
end
@ss.pos += 1
data.tr!(WHITESPACE, "")
[data].pack('H*')
end
# Parses the name at the current position.
#
# See: PDF1.7 s7.3.5
def parse_name
@ss.pos += 1
str = scan_until(WHITESPACE_OR_DELIMITER_RE) || @ss.scan(/.*/)
str.gsub!(/#[A-Fa-f0-9]{2}/) {|m| m[1, 2].hex.chr }
if str.force_encoding(Encoding::UTF_8).valid_encoding?
str.to_sym
else
str.force_encoding(Encoding::BINARY).to_sym
end
end
# Parses the array at the current position.
#
# It is assumed that the initial '[' has already been scanned.
#
# See: PDF1.7 s7.3.6
def parse_array
result = []
while true
obj = next_object(allow_end_array_token: true)
if obj.equal?(TOKEN_ARRAY_END)
break
elsif obj.equal?(NO_MORE_TOKENS)
raise HexaPDF::MalformedPDFError.new("Unclosed array found", pos: pos)
end
result << obj
end
result
end
# Parses the dictionary at the current position.
#
# It is assumed that the initial '<<' has already been scanned.
#
# See: PDF1.7 s7.3.7
def parse_dictionary
result = {}
while true
# Use #next_token because we either need a Name or the '>>' token here, the latter would
# throw an error with #next_object.
key = next_token
break if key.equal?(TOKEN_DICT_END)
unless key.kind_of?(Symbol)
raise HexaPDF::MalformedPDFError.new("Dictionary keys must be PDF name objects, " \
"found '#{key}'", pos: pos)
end
val = next_object
next if val.nil?
result[key] = val
end
result
end
# Prepares the StringScanner by filling its string instance with enough bytes.
#
# The number of needed bytes can be specified via the optional +needed_bytes+ argument.
#
# Returns +true+ if the end of the underlying IO stream has not been reached, yet.
def prepare_string_scanner(needed_bytes = nil)
return if needed_bytes && @ss.rest_size >= needed_bytes
@io.seek(@next_read_pos)
return false if @io.eof?
@ss << @io.read(8192)
if @ss.pos > 8192 && @ss.string.length > 16384
@ss.string.slice!(0, 8192)
@ss.pos -= 8192
@original_pos += 8192
end
@next_read_pos = @io.pos
true
end
# Calls the @on_correctable_error callable object with the given message and the current
# position. If the returned value is +true+, raises a HexaPDF::MalformedPDFError. Otherwise the
# error is corrected (by the caller) and tokenization continues.
#
# If the option +force+ is used, the callable object is not called and the error is raised
# immediately.
def maybe_raise(msg, force: false)
if force || @on_correctable_error.call(msg, pos)
error = HexaPDF::MalformedPDFError.new(msg, pos: pos)
error.set_backtrace(caller(1))
raise error
end
end
end
end