# ##### BEGIN GPL LICENSE BLOCK #####
#
#  This program is free software; you can redistribute it and/or
#  modify it under the terms of the GNU General Public License
#  as published by the Free Software Foundation; either version 2
#  of the License, or (at your option) any later version.
#
#  This program 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 General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program; if not, write to the Free Software Foundation,
#  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####

"""Reading various vector file formats.

Functions for classifying files, tokenizing, and parsing them.
The ultimate goal is to parse a file into an instance of the class Art,
which has the line segments, bezier segments, arc segments,
and faces specified in a vector file.
"""

__author__ = "howard.trickey@gmail.com"

import re
from . import geom
from . import pdf
from . import svg

WARN = True   # print Warnings about strange things?

# Token types

TNAME = 0
TLITNAME = 1
TSTRING = 2
TNUM = 3


def ClassifyFile(filename):
    """Classify file into one of known vector types.

    Args:
      filename: string, the name of the file to classify
    Returns:
      (string, string), giving maintype and version.
      If there's an error, returns ("error", reason-string)
    """

    if filename.endswith(".svg"):
        return ("svg", "")
    try:
        f = open(filename, "rb")
        start = f.read(25)
    except IOError:
        return ("error", "file open error")

    # Encapsulated Postscript files start like
    #   %!PS-Adobe-X.X EPSF-Y.Y
    # where the first number is the version of PostScript Document Structuring
    # Convention, and the second number is the level of EPSF.
    # Adobe Illustrator files, version 8 and earlier, have
    #   %%+ procset Adobe_Illustrator...
    # sometime before %%EndProlog
    if start.startswith(b"%!PS-Adobe-"):
        ans = ("ps", "")
        if start[14:20] == b" EPSF-":
            ans = ("eps", start[20:23].decode())
        if start[14:19] == b" PDF-":
            ans = ("pdf", start[19:22].decode())
        if ans[0] != "pdf" and _FindAdobeIllustrator(f):
            ans = ("ai", "eps")
    # PDF files start with %PDF
    # Adobe Illustrator files, version 9 and later, have
    #   %%+ procset Adobe_Illustrator...
    # sometime before %%EndProlog
    elif start.startswith(b"%PDF"):
        ans = ("pdf", start[5:8].decode())
        if _FindAdobeIllustrator(f):
            ans = ("ai", "pdf")
    else:
        ans = ("error", "unknown file type")
    f.close()
    return ans


def _FindAdobeIllustrator(f):
    """Does a file contain "Adobe_Illustrator"?

    Args:
      f: an open File
    Returns:
      bool: True if reading forward in f, we find "Adobe_Illustrator"
    """

    while True:
        s = f.readline()
        if not s or s.startswith(b"%%EndProlog"):
            break
        if s.find(b"Adobe_Illustrator") >= 0:
            return True
    return False


def ParseVecFile(filename):
    """Parse a vector art file and return an Art object for it.

    Right now, handled file types  are: EPS, Adobe Illustrator, PDF

    Args:
      filename: string - name of the file to read and parse
    Returns:
      geom.Art: object containing paths drawn in the file.
           Return None if there was a major problem reading the file.
    """

    (major, minor) = ClassifyFile(filename)
    if (major == "error"):
        print("Couldn't get Art:", minor)
        return None
    if major == "pdf" or (major == "ai" and minor == "pdf"):
        contents = pdf.ReadPDFPageOneContents(filename)
        if contents:
            toks = TokenizeAIEPS(contents)
            return ParsePS(toks, major, minor)
        else:
            return None
    elif major == "eps" or (major == "ai" and minor == "eps"):
        toks = TokenizeAIEPSFile(filename)
        return ParsePS(toks, major, minor)
    elif major == "svg":
        return svg.ParseSVGFile(filename)
    else:
        return None


def ParseAIEPSFile(filename):
    """Parse an AI (eps kind) file and return an Art object for it.

    Args:
      filename: string - name of the file to read and parse
    Returns:
      geom.Art - object containing paths and faces drawn in the file
    """

    toks = TokenizeAIEPSFile(filename)
    return ParsePS(toks, "ai", "eps")


def TokenizeAIEPSFile(filename):
    """Tokenize the after-setup part of an AI (eps kind) file.

    Runs TokenizeAIEPS (see below) on the contents of the file.

    Args:
      filename: name of the file to tokenize
    Returns:
      list of (tokenid, value) tuples
    """

    try:
        f = open(filename, "rU")  # 'U'-> all newline reps converted to '\n'
    except IOError:
        if WARN:
            print("Can't open file", filename)
        return []
    contents = f.read()
    f.close()
    return TokenizeAIEPS(contents)

# Regular expressions for PostScript tokens
_re_psname = re.compile(r"[^ \t\r\n()<>[\]{}/%]+")
_re_psfloat = re.compile(r"(\+|-)?(([0-9]+\.[0-9]*)|(\.[0-9]+))")
_re_psint = re.compile(r"(\+|-)?[0-9]+")
_re_psstring = re.compile(r"\((\\.|.)*?\)")
_re_pshexstring = re.compile(r"<.*>")


def TokenizeAIEPS(s):
    """Tokenize the after-setup part of the an AI (eps kind) string.

    Args:
      s: string to tokenize
    Returns:
      list of (Txxx, val) where Txxx is a token type constant
    """

    i = s.find("%%EndSetup")
    if i == -1:
        i = 0
    else:
        i += 10
    ans = []
    while i < len(s):
        c = s[i]
        if c.isspace():
            i += 1
        elif c == "%":
            i = s.find("\n", i)
            if i < 0:
                i = len(s)
                break
            i += 1
        elif c == "/":
            m = _re_psname.match(s, i + 1)
            if m:
                ans.append((TLITNAME, m.group()))
                i = m.end()
            else:
                if WARN:
                    print("empty name at", i)
                i += 1
        elif c == "(":
            m = _re_psstring.match(s, i)
            if m:
                ans.append((TSTRING, s[m.start() + 1:m.end() - 1]))
                i = m.end()
            else:
                if WARN:
                    print("unterminated string at", i)
                i = len(s)
        elif c == "<":
            m = _re_pshexstring.match(s, i)
            if m:
                ans.append((TSTRING, s[m.start() + 1:m.end() - 1]))
                i = m.end()
            else:
                if WARN:
                    print("unterminated hex string at", i)
                i = len(s)  # unterminated hex string
        elif c == "[" or c == "]" or c == "{" or c == "}":
            ans.append((TNAME, c))
            i += 1
        elif c == "-" or c.isdigit():
            m = _re_psfloat.match(s, i)
            if m:
                v = float(m.group())
                ans.append((TNUM, v))
                i = m.end()
            else:
                m = _re_psint.match(s, i)
                if m:
                    v = int(m.group())
                    ans.append((TNUM, v))
                    i = m.end()
                else:
                    if WARN:
                        print("number parse problem at", i)
                    i += 1
        else:
            m = _re_psname.match(s, i)
            if m:
                ans.append((TNAME, m.group()))
                i = m.end()
            else:
                if WARN:
                    print("tokenize error at", i, s[i:i + 10], "...")
                i += 1
    return ans


class GState(object):
    """Object to hold graphic state.

    Attributes:
      ctm: geom.TransformMatrix - current transform matrix
      fillpaint: geom.Paint
      strokepaint: geom.Paint
    """

    def __init__(self):
        self.ctm = geom.TransformMatrix()
        self.fillpaint = geom.black_paint
        self.strokepaint = geom.black_paint

    def Copy(self):
        """Return a copy of this graphics state."""

        gs = GState()
        gs.ctm = self.ctm.Copy()
        gs.fillpaint = self.fillpaint  # ok to share, paint is immutable
        gs.strokepaint = self.strokepaint
        return gs


class _PathState(object):
    """Object to hold state while parsing Adobe paths.

    Attributes:
      art: geom.Art, used  to accumulate answer
      curpath: geom.Path
      cursubpath: geom.Subpath - not yet added into curpath
      curpoint: coordinates of current point, None if none
      incompound: true if parsing an ai/eps compound path
      gstate: GState - the current graphics state
      gstack: list of GState - stack when graphics state pushed
      messages: list of string - warnings, errors
    """

    def __init__(self):
        """Construct the _PathState object."""

        self.art = geom.Art()
        self.ResetPath()
        self.incompound = False
        self.gstate = GState()
        self.statestack = []
        self.messages = []

    def CloseSubpath(self):
        """Close the current subpath.

        Close the current subpath by appending a straight line segment from
        current point to starting point of the subpath, terminating current
        subpath.
        Does nothing if current subpath is already closed or is empty.
        """

        if not self.cursubpath.Empty():
            startp = geom.Subpath.SegStart(self.cursubpath.segments[0])
            if startp != self.curpoint:
                self.cursubpath.AddSegment(("L", self.curpoint, startp))
                self.curpoint = startp
            self.curpath.AddSubpath(self.cursubpath)
            self.cursubpath = geom.Subpath()

    def ResetPath(self):
        """Reset the current path state to empty,
        discarding any current path."""

        self.curpath = geom.Path()
        self.cursubpath = geom.Subpath()
        self.curpoint = None
        self.incompound = False

    def StartCompound(self):
        """Mark entry to an ai/eps compound path."""

        self.incompound = True

    def EndCompound(self):
        """Finish off an ai/eps compound path."""

        if not self.curpath.Empty():
            self.art.paths.append(self.curpath)
        self.ResetPath()

    def DrawPath(self, dofill, dostroke, fillevenodd=False):
        """End the current path and add its subpaths to art.

        Assume any finally closing of the current subpath, if needed,
        was done separately.
        If we are in an ai/eps compound path, don't close off the
        current path yet - wait until EndCompound - but record
        the fill/stroke parameters for later use.

        Arguments:
          dofill: if true, the path is to be filled
          dostroke: if true, the path is to be stroked
          fillevenodd: it true, use even-odd fill rule,
              else nonzero winding number rule
        """

        if not self.cursubpath.Empty():
            self.curpath.AddSubpath(self.cursubpath)
            self.cursubpath = geom.Subpath()
        p = self.curpath
        if not p.Empty():
            p.filled = dofill
            p.fillevenodd = fillevenodd
            p.stroked = dostroke
            if dofill:
                p.fillpaint = self.gstate.fillpaint
            if dostroke:
                p.strokepaint = self.gstate.strokepaint
            if not self.incompound:
                self.art.paths.append(p)
                self.ResetPath()
        elif not self.incompound:
            self.ResetPath()

    def MoveTo(self, x, y, relative=False):
        """Begin a new subpath, starting at (x,y).

        If the previous path construction was also a MoveTo,
        its effect is overridden.
        If relative is True, the move should be relative
        to the previous point, else it is absolute.

        Args:
          x: float
          y: float - the 2d coord to start at
          relative: bool - if true, then a relative move, else absolute
        """

        (xp, yp) = self.gstate.ctm.Apply((x, y))
        if relative and self.curpoint:
            xp += self.curpoint[0]
            yp += self.curpoint[1]
        p = (xp, yp)
        if not self.cursubpath.Empty():
            self.curpath.AddSubpath(self.cursubpath)
        self.cursubpath = geom.Subpath()
        self.curpoint = p

    def LineTo(self, x, y, relative=False):
        """Append a straight line segment from current point to (x,y).

        Does nothing if there is no current point, or the segment
        would have no length.
        If relative is True, the endpoint of the line is relative to the start.

        Args:
          x: float
          y: float - the 2d coord to make the line to.
          relative: bool - if true, then a relative lineto
        """

        if self.curpoint == -1:
            return
        (xp, yp) = self.gstate.ctm.Apply((x, y))
        if relative and self.curpoint:
            xp += self.curpoint[0]
            yp += self.curpoint[1]
        p = (xp, yp)
        if p != self.curpoint:
            self.cursubpath.AddSegment(("L", self.curpoint, p))
            self.curpoint = p

    def Bezier3To(self, x, y, cp1x, cp1y, cp2x, cp2y,
                  use_start_as_cp=False, relative=False):
        """Append a cubic bezier curve from current point to (x,y).

        Args:
          x: float
          y: float - the 2d coord that ends the curve
          cp1x: float
          cp1y: float - first bezier control point
          cp2x: float
          cp2y: float - second bezier control point
          use_start_as_cp: bool - if True, ignore cp1x,cp2y and use current
              point as first control point instead
          relative: bool - if True, all coords are relative to previous point
        """

        if self.curpoint == -1:
            return
        (rx, ry) = (0, 0)
        if relative and self.curpoint:
            (rx, ry) = self.curpoint
        if use_start_as_cp:
            cp1 = self.curpoint
        else:
            cp1 = self.gstate.ctm.Apply((cp1x + rx, cp1y + ry))
        cp2 = self.gstate.ctm.Apply((cp2x + rx, cp2y + ry))
        p = self.gstate.ctm.Apply((x + rx, y + ry))
        self.cursubpath.AddSegment(("B", self.curpoint, p, cp1, cp2))
        self.curpoint = p

    def PushGState(self):
        """Push the graphics state, leaving a copy in gstate."""

        newgstate = self.gstate.Copy()
        self.statestack.append(self.gstate)
        self.gstate = newgstate

    def PopGState(self):
        """Pop the graphics state (no-op if stack is empty)."""

        if self.statestack:
            self.gstate = self.statestack.pop()


def ParsePS(toks, major="pdf", minor=""):
    """Parse a Postscript-like token list into an Art object.

    Four kinds of files use approximately the same painting
    model and operators:
      Encapsulated Postscript (EPS) - Postscript with Document
        Structuring Convention Comments: in general, these
        can have Postscript procedures and are not handled
        by the code here, but many programs producing eps
        just use the path creating/painting operators or
        abbreviations for them.
      Adobe Illustrator, version <=8: Uses EPS but with
        paths are all just single subpaths unless enclosed
        in compound path brackets (*u ... *U)
      Adobe Illustrator, version >=9: PDF for page description
      PDF: similar to Postscript, but some different operators

     We can parse each into an Art structure using approximately
     the same code.

    Args:
      toks: list of (Txxx, val), result of Tokenizing a file
      major: string - major version ("ps", "eps", "pdf", or "ai")
      minor: string - minor version (version number for ps, eps, pdf,
                      and "eps" or "pdf" for "ai")
    Returns:
      geom.Art: object with the paths painted by the token stream
    """

    pstate = _PathState()
    i = 0
    while i < len(toks):
        (t, v) = toks[i]
        i += 1
        if t == TNAME:
            # zero-operand operator or unhandled one
            # since all handled multi-operand operators
            # are handled below
            if v == "h" or v == "H" or v == "closepath":
                pstate.CloseSubpath()
            elif v == "f" or v == "F" or v == "fill":
                # fill path using nonzero winding number rule
                pstate.DrawPath(True, False, False)
            elif v == "f*" or v == "eofill":
                # fill path using even-odd rule
                pstate.DrawPath(True, False, True)
            elif v == "s":
                # close and stroke path
                pstate.CloseSubpath()
                pstate.DrawPath(False, True)
            elif v == "S" or v == "stroke":
                # stroke path
                pstate.DrawPath(False, True)
            elif v == "b":
                # close, fill and stroke path using nonzero winding rule
                pstate.CloseSubpath()
                pstate.DrawPath(True, True, False)
            elif v == "B":
                # fill and stroke path uwing nonzero winding rule
                pstate.DrawPath(True, True, False)
            elif v == "b*":
                # close, fill and stroke path using even-odd rule
                pstate.CloseSubpath()
                pstate.DrawPath(True, True, True)
            elif v == "B*":
                # fill and stroke path using even-odd rule
                pstate.DrawPath(True, True, True)
            elif v == "n" or v == "N" or v == "newpath":
                # finish path no-op, probably after clipping
                # (which is not handled yet)
                pstate.ResetPath()
            elif v == "*u" and major == "ai" and minor == "eps":
                # beginning of AI compound path
                pstate.StartCompound()
            elif v == "*U" and major == "ai" and minor == "eps":
                # end of AI compound path
                pstate.EndCompound()
            elif v == "q" or v == "gsave":
                pstate.PushGState()
            elif v == "Q" or v == "grestore":
                pstate.PopGState()
        elif t == TNUM:
            # see if have nargs numbers followed by an op name
            op = ""
            args = [float(v)]
            iend = min(i + 6, len(toks))
            while i < iend:
                t = toks[i][0]
                if t == TNUM:
                    args.append(float(toks[i][1]))
                    i += 1
                elif t == TNAME:
                    op = toks[i][1]
                    i += 1
                    break
                else:
                    break
            if op and len(args) <= 6:
                if len(args) == 1:
                    if op == "g":
                        # gray level for non-stroking operations
                        pstate.gstate.fillpaint = geom.Paint(args[0],
                            args[0], args[0])
                    elif op == "G":
                        pstate.gstate.strokepaint = geom.Paint(args[0],
                            args[0], args[0])
                if len(args) == 2:
                    if op == "m" or op == "moveto":
                        pstate.MoveTo(args[0], args[1], False)
                    elif op == "rmoveto":
                        pstate.MoveTo(args[0], args[1], True)
                    elif op == "l" or op == "L" or op == "lineto":
                        pstate.LineTo(args[0], args[1], False)
                    elif op == "rlineto":
                        pstate.LineTo(args[0], args[1], True)
                    elif op == "scale":
                        pstate.gstate.ctm.ComposeTransform(args[0], 0.0,
                            0.0, args[1], 0.0, 0.0)
                    elif op == "translate":
                        pstate.gstate.ctm.ComposeTransform(0.0, 0.0,
                            0.0, 0.0, args[0], args[1])
                if len(args) == 3:
                    if op == "rg" or op == "scn":
                        # rgb for non-stroking operations
                        # For scn should really refer to Color space from
                        # cs operator, which in turn may need to look in
                        # Resource Dictionary in pdf,
                        # so for now punt and assume rgb if three operands
                        pstate.gstate.fillpaint = geom.Paint(args[0],
                            args[1], args[2])
                    elif op == "RG" or op == "SCN":
                        pstate.gstate.strokepaint = geom.Paint(args[0],
                            args[1], args[2])
                elif len(args) == 4:
                    if op == "v" or op == "V":
                        # cubic bezier but use start as first cp
                        pstate.Bezier3To(args[2], args[3], 0.0, 0.0,
                                         args[0], args[1],
                                         use_start_as_cp=True)
                    elif op == "y" or op == "Y":
                        # cubic bezier but use last as second cp
                        pstate.Bezier3To(args[2], args[3], args[0], args[1],
                                         args[2], args[3])
                    elif op == "re" or op == "rectfill" or op == "rectstroke":
                        # rectangle with x, y, width, height as args
                        # drawn as complete subpath  (a PDF operator)
                        x = args[0]
                        y = args[1]
                        w = args[2]
                        h = args[3]
                        pstate.MoveTo(x, y)
                        pstate.LineTo(x + w, y)
                        pstate.LineTo(x + w, y + h)
                        pstate.LineTo(x, h + y)
                        pstate.CloseSubpath()
                        if op == "rectfill":
                            pstate.DrawPath(True, False)
                        elif op == "rectstroke":
                            pstate.DrawPath(False, True)
                    elif op == "k" or op == "scn":
                        # cmyk for non-stroking operations
                        # For scn should really refer to Color space from
                        # cs operator, which in turn may need to look in
                        # Resource Dictionary in pdf,
                        # so for now punt and assume cmyk if four operands
                        pstate.gstate.fillpaint = geom.Paint.CMYK(args[0],
                            args[1], args[2], args[3])
                    elif op == "K" or op == "SCN":
                        pstate.gstate.strokepaint = geom.Paint.CMYK(args[0],
                            args[1], args[2], args[3])
                elif len(args) == 6:
                    if op == "c" or op == "C" or op == "curveto":
                        # corner and non-corner cubic beziers
                        pstate.Bezier3To(args[4], args[5], args[0], args[1],
                                         args[2], args[3], False, False)
                    elif op == "rcurveto":
                        pstate.Bezier3To(args[4], args[5], args[0], args[1],
                                         args[2], args[3], False, True)
                    elif op == "cm" or op == "concat":
                        pstate.gstate.ctm.ComposeTransform(args[0], args[1],
                            args[2], args[3], args[4], args[5])
    return pstate.art


# Notes on Adobe Illustrator post version 8:
# Outside format is PDF.
# A Page object may have a PieceInfo with Illustrator attribute
# pointing to an object with a Private attribute that points to
# an object with AIMetaData and AIPrivateData[123456]
# AIMetaData points to the prolog of an old-style AI file
# AIPrivate1 points to a thumbnail image
# AIPrivate2-6 point to compressed stream objects - need more investigation.
# But AI version12 does different stuff: has AIPrivateData1-6 etc.
# It appears that AIPrivate6 obj has the %EndSetup and then old-style AI file
# So: hacky way that will sometimes work:
# 1) find "/AIPrivateData6 Z 0 R"  for some Z
# 2) find "Z 0 obj"
# 3) find following stream, and then endstream
# 4) flatedecode the stream if necessary
# 5) look for "%%EndSetup, if found: tokenize and parse like old AI files
