class PdfError(Exception):
    """Generic PDF exception"""
    pass class PdfParseError(PdfError):
    """Exception parsing PDF file"""
    pass def buffer_data(data):
    """Wrap the data in a BufferedReader if we need to."""
    if _is_buffered_bytesio(data):
        return data
    elif isinstance(data, io.BytesIO):
        return io.BufferedReader(data)
    elif isinstance(data, (bytes, bytearray)):
        return io.BufferedReader(io.BytesIO(data))
    else:
        try:
            return io.BufferedReader(io.BytesIO(bytes(data)))
        except TypeError:
            raise TypeError('Data to be parsed must be either bytes, '
                            'bytesarray, or a read()able stream.') def read_until(data, char_set):
    """Reads buffered io object until an element of char_set is encountered,
    returning the read data without the terminator."""
    result = io.BytesIO()
    char_set = set(char_set)
    c = data.read(1)
    while c and c not in char_set:
        result.write(c)
        c = data.read(1)
    return result.getvalue() def force_decode(bstring):
    """Tries to decode a bytestring to text.  If that fails, just repr it."""
    try:
        return bstring.decode()
    except UnicodeDecodeError:
        return repr(bstring)[2:-1] def consume_whitespace(data, whitespace=WHITESPACE):
    """Reads buffered io object data until a non-whitespace byte is encountered
    and leaves the file position at that character."""
    whitespace = set(whitespace)
    for c in whitespace:
        break
    while c and c in whitespace:
        c = data.read(1)
    if c and c not in whitespace:
        data.seek(-1, 1) # Rewind 1 byte def is_digit(val):
    """Returns True if chr(val) is a digit 0-9"""
    return  48 <= val <= 57 class ReCacher(object):
    """Passes calls and arguments through to re and caches the results.
    Usage:

    rc = ReCacher()
    if   rc.match(<pattern1>, <string>): do_something(rc.group(3))
    elif rc.match(<pattern1>, <string>): do_something(rc.group(2))

    The return value from the last call can also be accessed through the
    .value property."""

    def __init__(self):
        self._retval = None

    @property
    def value(self):
        """Whatever the last method call returned"""
        return self._retval

    def __getattr__(self, name):
        try:
            re_attr = getattr(re, name)
            if callable(re_attr):
                re_attr = self._cache_wrapper(re_attr)
            return re_attr
        except AttributeError:
            return self._retval.__getattribute__(name)

    def _cache_wrapper(self, fn):
        @wraps(fn)
        def cached(*args, **kwargs):
            self._retval = fn(*args, **kwargs)
            return self._retval
        return cached def int_from_bytes(val):
    """Converts val to an unsinged int assuming a BE byte order"""
    try:
        return int.from_bytes(val, 'big')
    except AttributeError:
        # Older Python, so pad it and unpack
        return struct.unpack('>L', b'\x00'*(4-len(val))+val)[0] EOLS       = frozenset((b'\r', b'\n', b'\r\n')) class PdfParser(object):
    """Parser for PDF files.  Takes raw PDF data and turns it into PDF _types_,
    which can then be assembled into a document and document elements."""
    DELIMITERS = set([b'/', b'<', b'(', b'{', b'[', b'%'])
    ENDERS     = WHITESPACE.union(DELIMITERS)

    def __init__(self, document=None):
        """Initialize the PdfParser with a default PdfDocument"""
        from .pdf_doc import PdfDocument
        if document is None or isinstance(document, PdfDocument):
            self._doc = document
        else:
            raise PdfParseError('document must be either None or a PdfParser')

    def parse_simple_object(self, data, position=None):
        """Parse and return the simple object (i.e., not an indirect object)
        described in the first argument located at either current stream
        position or the position specified by the optional argument.  The
        stream's position will be left at the end of the object.

        This method should only be used in places where an indirect object
        reference is not valid."""
        if position is not None:
            data.seek(position)
        token = self._get_next_token(data)
        return self._process_token(data, token, None)

    def parse_indirect_object(self, data, position=None):
        """Parse and return the indirect object described in the first argument
        located at either current stream position or the position specified by
        the optional argument.  The stream's position will be left at the end of the object."""
        if position is not None:
            data.seek(position)
        obj_no  = self._process_token(data, self._get_next_token(data), None)
        obj_gen = self._process_token(data, self._get_next_token(data), None)
        if not isinstance(obj_no, int) or not isinstance(obj_gen, int):
            raise PdfParseError('Object identification not found')
        token   =  self._get_next_token(data)
        if token != b'obj':
            raise PdfParseError("Expected 'obj', got '{}'".format(token))
        return self.parse_ind_object(data, [obj_no, obj_gen])

    def _get_objects(self, data, closer=None):
        """Get all of the objects in data starting from the current position
        until hitting EOF or the optional closer argument.  Returns a list of
        PdfTypes, ints, floats, and bools.

        TODO: Restore PdfInt, etc."""
        objects = []
        while data.peek(1):
            token = self._get_next_token(data, closer)
            if not token: continue
            if token == closer: break
            element  = self._process_token(data, token, objects)
            if token not in (b'obj', b'xref'):
                objects.append(element)
        return objects

    def parse_list(self, data, allow_invalid=False, disallowed=frozenset()):
        """Parse the data and return a list"""
        data = buffer_data(data)
        return [i for i in self.iterparse(data, allow_invalid, disallowed)]

    def iterparse(self, data, allow_invalid=True,
                  disallowed=frozenset({b'R', b'obj', b'stream'})):
        """Generator-parser primarily for use in content streams."""
        data = buffer_data(data)
        while data.peek(1):
            token = self._get_next_token(data, disallowed=disallowed)
            if not token: continue
            element  = self._process_token(data, token, BlackHole(),
                                           allow_invalid)
            yield element
            if isinstance(element, PdfRaw) and element == b'BI':
                for i in self._parse_inline_image(data, disallowed):
                    yield i

    def _parse_inline_image(self, data, disallowed):
        """Special method for handling inline images in content streams because
        they are absolutely awful.

        See Reference pp. 352-355"""
        attrs = []
        token = None
        while data.peek(1) and token != b'ID':
            token = self._get_next_token(data, disallowed=disallowed)
            if not token: continue
            attrs.append(self._process_token(data,token,BlackHole, True))
        yield PdfDict({attrs[i]:attrs[i+1] for i in range(0,len(attrs)-1,2)})
        data.read(1)
        img = io.BytesIO()
        buf = bytes(2)
        while buf != b'EI':
            buf = buf[1:]+data.read(1)
            img.write(buf[1:])
        yield PdfRawData(img.getvalue()[:-2]) # This is such an ugly hack
        yield PdfRaw(b'EI')

    @staticmethod
    def _peek(data, n=1):
        """Peek ahead, returning the requested number of characters.  If peek()
        doesn't yield enough data, read and backup."""
        if n <= 0: return b''
        res = data.peek(n)[:n]
        if len(res) == n:
            return res
        res += data.read(n-len(res))
        data.seek(-len(res), 1)
        return res

    @classmethod
    def _get_next_token(cls, data, closer=None, disallowed=frozenset()):
        """Get the next token in the stream, data.  Closer is an optional
        argument specifying the ending token of the current data structure,
        e.g., >> for dicts."""
        clen  = len(closer) if closer is not None else None
        token = io.BytesIO()
        consume_whitespace(data, WHITESPACE)

        while data.peek(1) and (token.getvalue() != closer) \
           and not cls._is_token(data, token.getvalue(),
                                 closer, clen, disallowed):
            token.write(data.read(1))
        return token.getvalue()

    @classmethod
    def _is_token(cls, data, value, closer=None, clen=None,
                  disallowed=frozenset()):
        """Is this a token?"""
        if closer and not clen:
            clen = len(closer)

        if   not data.peek(1): return True
        elif not value:        return False

        next_char = cls._peek(data, 1)
        not_obj   = (value+next_char) not in cls.obj_types

        if value in cls.obj_types and not_obj and value not in disallowed:
            return True
        elif closer and cls._peek(data, clen) == closer \
            and value+cls._peek(data, clen-len(value)) != closer: #Last clause covers an issue with
            return True                                       #a dict as the last element of a dict
        elif next_char in cls.ENDERS and not_obj:
            return True
        return False

    def _process_token(self, data, token, objects, allow_invalid=False):
        """Process the data at the current position in the stream data into the
        data type indicated by token.

        Optional arguments:
            objects       - A list of objects already known.
            allow_invalid - Don't raise an exception when an invalid token is
                            encountered, instead returning a PdfRaw object."""
        try:
            return self.obj_types[token](self, data, objects)
        except KeyError:
            try:
                return self.parse_literal(token)
            except PdfParseError:
                #This lets us use this parse Content Streams
                if allow_invalid: return PdfRaw(token)
                else:             raise

    def parse_reference(self, data, objects):
        """References an indirect object, which may or may not have already
        been defined."""
        generation = objects.pop()
        obj_no     = objects.pop()
        return PdfObjectReference(obj_no, generation, self._doc)

    def parse_dict(self, data, objects):
        """A dict is just represented as a differently delimited array, so
        we'll call that to get the elements"""
        elems = self.parse_array(data, objects, b'>>')
        return PdfDict(zip(elems[::2], elems[1::2]))

    def parse_hex_string(self, data, objects):
        """Extract a PdfHexString from raw data"""
        token = io.BytesIO(data.read(1))
        token.seek(0, 2)
        while data.peek(1) and token.getvalue()[-1:] != b'>':
            token.write(data.read(1))
        return PdfHexString(token.getvalue()[:-1])

    def parse_literal_string(self, data, objects):
        """Extract a PdfLiteralString from raw data"""
        token   = io.BytesIO()
        parens  = 0
        escaped = False
        while data.peek(1):
            b = data.read(1)
            if  escaped:
                escaped = False
            elif b == b'\\':
                escaped = True
            elif b == b'(':
                parens += 1
            elif b == b')':
                if parens == 0:
                    return PdfLiteralString(token.getvalue())
                else:
                    parens -= 1
            token.write(b)
        raise PdfParseError('Unterminated string literal')
    def parse_array(self, data, objects, closer=b']'):
        """Extract a PdfArray from the data stream"""
        elems = self._get_objects(data, closer)
        return PdfArray(elems)

    def parse_comment(self, data, objects):
        """Extract a PdfComment from the data stream"""
        token = io.BytesIO()
        while data.peek(1):
            b = data.read(1)
            if b in EOLS: break
            token.write(b)
        else:
            return PdfComment(token.getvalue())

    def parse_expression(self, data, objects):
        """TODO: This"""
        pass

    def parse_ind_object(self, data, objects):
        """Extract an indirect object from the data stream"""
        gen     = objects.pop()
        obj_no  = objects.pop()
        obj     = self._get_objects(data, closer=b'endobj')
        return  PdfIndirectObject(obj_no, gen, obj[0] if obj else None,
                                  self._doc)

    def parse_stream(self, data, objects):
        """Extract a PdfStream from the data stream"""
        header = objects.pop()
        lngth  = header['Length']
        if isinstance(lngth, PdfObjectReference):
            lngth = lngth.value
        if data.peek(1)[:1] == b'\r': data.read(1)
        if data.peek(1)[:1] == b'\n': data.read(1)
        s_data = data.read(lngth)
        # Long peeks are not guaranteed to work, so we're going to do this
        # hackish read/seek for now
        close = data.read(11)
        if   close      == b'\r\nendstream':
            pass
        elif close[:-1] == b'\nendstream':
            data.seek(-1, 1)
        elif close[:-2] == b'endstream':
            data.seek(-2, 1)
        else:
            raise PdfParseError('endstream not found')
        return PdfStream(header, s_data)

    @staticmethod
    def parse_literal(token):
        """Parse a simple literal number, boolean, or null"""
        token = bytes(token)
        if   token == b'true':  return True
        elif token == b'false': return False
        elif token == b'null':  return None
        elif token[:1] == b'/':
            return PdfParser.parse_name(token)
        else:
            try:
                return PdfParser.parse_number(token)
            except ValueError:
                raise PdfParseError('Invalid token found: '+repr(token))

    @staticmethod
    def parse_number(token):
        """Extract a numeric type from the data stream"""
        try:
            return int(token)
        except ValueError:
            return float(token)

    @staticmethod
    def parse_name(token):
        """Parse the token into a PdfName"""
        return PdfName.from_token(token)

    # dict of PDF object types besides literals to look for.
    # Keys are the token that identifies that beginning of that type,
    # and values are method that does the parsing
    # This dict does not need to include simple literals (numerics, booleans
    # nulls, and names).  Each of these processing functions takes one argument,
    # the current scope's objects list.
    obj_types = {b'<<'       : parse_dict,
                 b'<'        : parse_hex_string,
                 b'('        : parse_literal_string,
                 b'['        : parse_array,
                 b'%'        : parse_comment,
                 b'{'        : parse_expression,      # TODO
                 b'R'        : parse_reference,
                 b'obj'      : parse_ind_object,
                 b'stream'   : parse_stream,
                }

    # List methods
    def append(self):    return None class PdfDict(PdfType, UserDict):
    """PDF dict type"""
    def __init__(self, *args, **kwargs):
        PdfType.__init__(self)
        UserDict.__init__(self, *args, **kwargs)

    def __getattr__(self, name):
        try:
            return self[name].parsed_object
        except AttributeError:
            return self[name]
        except KeyError:
            raise AttributeError('Object has no attribute "{}"'.format(name))
    def pdf_encode(self):
        return b'<<'+b' '.join(k.pdf_encode()+b' '+v.pdf_encode()
                               for k, v in six.iteritems(self))+b'>>' class PdfObjectReference(PdfType):
    """PDF indirect object reference"""
    def __init__(self, object_number, generation, document=None):
        super(PdfObjectReference, self).__init__()
        self._object_number = object_number
        self._generation    = generation
        self._document      = document

        if   not isinstance(self._object_number, int) \
          or not isinstance(self._generation,    int) \
          or self._object_number <= 0 or self._generation < 0:
            raise ValueError('Invalid indirect object identifier')

    def get_object(self, document=None):
        if not document and not self._document:
            raise PdfError('Evaluating indirect references requires a document')
        obj_id = (self._object_number, self._generation)
        return (document if document else self._document).get_object(*obj_id)
    @property
    def value(self):
        return self.get_object().value
    @property
    def parsed_object(self):
        return self.get_object().parsed_object

    def __str__(self):
        return 'PdfObjectReference({0}, {1})'.format(self._object_number,
                                                     self._generation)
    def __repr__(self):
        return str(self)
    def pdf_encode(self):
        return '{0} {1} R'.format(self._object_number,
                                  self._generation).encode() class PdfHeader(PdfType):
    """PDF version header.  Probably not super necessary to have."""
    def __init__(self, version, adobe_version=None):
        super(PdfHeader, self).__init__()
        self.version       = Decimal(version)
        self.adobe_version = Decimal(adobe_version) if adobe_version else None
    def __str__(self):
        vers = '%'
        if self.adobe_version:
            vers += '!PS-Adobe-'+str(self.adobe_version)+' '
        return vers+'PDF-'+str(self.version)
    def __bytes__(self):
        return bytes(str(self))
    def pdf_encode(self):
        return str(self).encode() class PdfXref(PdfType):
    """Cross reference objects.  These forms the basic scaffolding of the PDF
    file, indicating where in the file each object is located."""
    LINE_PAT = re.compile(r'^(\d{10}) (\d{5}) (n|f)\s{0,2}$')

    def __init__(self, document, obj_no, offset, generation, in_use):
        super(PdfXref, self).__init__()
        self._obj_no     = obj_no
        self._offset     = offset
        self._generation = generation
        self._in_use     = in_use
        self._document   = document
    @property
    def key(self):
        return (self._obj_no, self._generation)

    @property
    def value(self):
        return self.get_object()

    def get_object(self):
        """Return the object referenced by this Xref.  If it's already parsed
        in the document, great, otherwise parse it."""
        if self._in_use:
            objs = self._document.indirect_objects
            try:
                return objs[self.key]
            except KeyError:
                self._document.parse_object(self._offset)
                return objs[self.key]
        else:
            return None # TODO: implement free Xrefs
    def pdf_encode(self):
        fstr = '{0d10} {1d05} {2}\r\n'
        return fstr.format(self._offset, self._generation,
                           'n' if self._in_use else 'f').encode()

    def __str__(self):
        return '{:010d} {:010d} '.format(self._offset, self._generation)\
              +('n' if self._in_use else 'f')

    @classmethod
    def from_line(cls, document, obj_id, line):
        """Takes a line in an xref subsection and returns a PdfXref object.

        Arguments:
            document - The PdfDocument in which the line resides
            obj_id   - The xref line's object id based on the subsection header
            line     - The actual 18 to 20 byte line (possibly with whitespace)"""
        # TODO: consider using ReCacher here
        match = re.match(cls.LINE_PAT, line)
        if not match:
            raise PdfParseError('Invalid xref line')
        return cls(document, obj_id, int(match.group(1)), int(match.group(2)),
                   match.group(3) == 'n')
from .exc           import PdfError, PdfParseError
from .misc          import buffer_data, read_until, force_decode, \
                           consume_whitespace, is_digit, ReCacher, \
                           int_from_bytes
from .pdf_constants import EOLS
from .pdf_parser    import PdfParser
from .pdf_types     import PdfHeader, PdfXref, PdfObjectReference, PdfDict
"""
The main PDF Document class
"""


__all__ = ['PdfDocument']

class PdfDocument(object):
    """The main PDF Document class"""
    _opened_file = False

    def __init__(self, data):
        """Initialize a new PdfDocument based on data.

        Arguments:
            data - Either a binary string or a binary, readable stream
                   (e.g, BytesIO or a binary mode file)"""
        if isinstance(data, str):
            data = open(data, 'rb')
            self._opened_file = True
        self._data = buffer_data(data)
        self._parser = PdfParser(self)
        # These get used in parse()
        self._pages       = None
        self._version     = None
        self._ind_objects = {}
        self._xrefs       = None
        self._page_index  = None

    def parse(self):
        """Parse the data into a workable PDF document"""
        header         = self._get_header(self._data)
        xrefs, trailer = self._get_structure()

        self._version     = header.version
        self._xrefs       = xrefs
        self._build_doc(trailer)
        return self

    def _build_doc(self, trailer):
        """Use the information in trailer to build the document structure"""
        try:
            self._root = trailer['Root'].parsed_object
        except KeyError:
            raise PdfError('PDF file lacks a root element.  Is it protected?')
        self._version = trailer.get('Version', self._version)
        try:
            self._info = trailer['Info'].parsed_object
        except KeyError:
            self._info = None
        self._size = trailer['Size']
        try:
            self._encrypt = trailer['Encrypt'].parsed_object
        except KeyError:
            self._encrypt = None
        try:
            self._id = trailer['ID'].value
        except KeyError:
            self._id = None

    def parse_object(self, offset):
        """Parse the indirecte object located at the specified offset and add
        it to the documents objects dict, returning the stream position to its
        initial location"""
        pos = self._data.tell()
        obj = self._parser.parse_indirect_object(self._data, offset)
        self.indirect_objects[obj.object_key] = obj
        self._data.seek(pos)

    def __del__(self):
        """Cleanup on deletion"""
        if self._opened_file:
            self._data.close()

    def _get_structure(self, startxref=None):
        """Build the basic document structure.  Xrefs and trailers can come in
        two forms: either as literals in the file or as stream objects.  When
        presented as objects, the stream header also acts as the trailer, and
        the stream data is a set of xref records.  As far as I can tell, there
        is no rule against mixing and matching."""

        if not startxref:
            startxref = self._get_startxref(self._data)

        self._data.seek(startxref)
        if self._data.read(1).isdigit():
            xrefs, trailer = self._get_xref_stream(startxref)
        else:
            xrefs   = self._get_xref_table(startxref)
            trailer = self._get_trailer()
        try:
            new_xrefs, new_trailer = self._get_structure(trailer['Prev'])
        except KeyError:
            pass
        else:
            xrefs.update(new_xrefs)
            trailer.update(new_trailer)
        return xrefs, trailer

    @staticmethod
    def _get_header(data):
        """Return a PdfHeader object representing the header data from the PDF
        file in data"""
        data.seek(0)
        if data.read(1) != b'%':

            raise PdfParseError('PDF version header not found')