# Copyright (c) 2001, 2002, 2003, 2004 by Intevation GmbH
# Authors:
#     Markus Rechtien <markus@intevation.de>
#     Bernhard Reiter <bernhard@intevation.de>
#
# This program is free software under the GPL (>=v2)
# Read the file COPYING coming with Thuban for details.


"""
Classes needed to write a session in SVG format.
"""

# For compatibility with python 2.2
from __future__ import generators


__version__ = "$Revision: 1.16 $"
# $Source: /thubanrepository/thuban/Extensions/svgexport/svgmapwriter.py,v $
# $Id: svgmapwriter.py,v 1.16 2004/12/27 17:00:15 bernhard Exp $

import sys

# Verboseness level for debugging. Useful values: 0,1,2,3
verbose=0
log=sys.stdout.write

# Regular expressions used with Fontnames
import re
# Combining strings
from string import join
# We need to determine some object types
from types import ListType
# for SetBaseID
import binascii 

from Thuban import _
from Thuban.Model.data import SHAPETYPE_POINT
# VirtualDC extends XMLWriter
from Thuban.Model.xmlwriter import XMLWriter, escape
# Color related classes from the model of thuban
from Thuban.Model.color import Transparent, Black
# The SVGRenderer is subclass of BaseRenderer
from Thuban.UI.baserenderer import BaseRenderer

# Basic font map.
fontMap = { "Times"     : re.compile("Times-Roman.*"),
            "Helvetica" : re.compile("Helvetica.*"),
            "Courier"   : re.compile("Courier.*"),
            }

# Possible values for svg line joins.
svg_joins = {'miter':'miter', 'round':'round', 'bevel':'bevel'}
# Possible values for svg line caps.
svg_caps = {'':'', 'butt':'butt', 'round':'round', 'square':'square'}

#
# Some pseudo classes to be compatible with the Baserenderer-class.
#
class Point:
    """Simple Point class to save x,y coordinates."""
    def __init__(self, xp=0, yp=0):
        self.x = xp
        self.y = yp

    def __repr__(self):
        return "Point(%s, %s)" % (str(self.x), str(self.y))

class Trafo:
    """Class for transformation properties transfer."""
    def __init__(self):
        self.trafos = []
    
    def Append(self, type, coeffs):
        """Append a transformation to the list."""
        self.trafos.append((type, coeffs))
    
    def Count(self):
        """Get the number of transformations in list."""
        return len(self.trafos)
    
    def Pop(self):
        """Pop and return a transformation from the end of the list."""
        if len(self.trafos) > 0:
            return self.trafos.pop()
        else: return None

class Pattern:
    def __init__(self, solid=1):
        self.solid = solid

class Pen:
    """Pen object for property transfer."""
    def __init__(self, pcolor = Black, pwidth = 1, pdashes = None):
        self.color = pcolor
        self.width = pwidth
        self.dashes = pdashes
        self.join = 'round' 
        self.cap = 'round'

    def __str__(self):
        return "Pen(%s,%s,%s,%s,%s)" % \
            (str(self.color), str(self.width), str(self.dashes), 
             str(self.join), str(self.cap))
    
    def GetColor(self):
        return self.color

    def GetWidth(self):
        return self.width

    def GetJoin(self):
        return self.join
    
    def GetCap(self):
        return self.cap
    
    def GetDashes(self):
        if self.dashes is None or self.dashes is SOLID:
            return []
        else: return self.dashes

class Brush:
    """Brush property class."""
    def __init__(self, bfill=Black, bpattern=None):
        """Init the brush with the given values."""
        self.fill = bfill
        self.pattern = bpattern

    def __str__(self):
        return "Brush(" + str(self.fill) + ", " + str(self.pattern) + ")"

    def GetColor(self):
        return self.fill

    def GetPattern(self):
        return self.pattern

class Font:
    """Font class that accts as property object."""
    def __init__(self, ffamily='Helvetica', fsize=12):
        """Init the font with the given values."""
        self.family = ffamily
        self.size = fsize
    
    def GetFaceName(self):
        """Return the fontfamily the font belongs to."""
        return self.family
    
    def GetPointSize(self):
        """Return the size of the font in points."""
        return self.size

# Instantiate an empty pen.
TRANSPARENT_PEN = Pen(None, 0, None)
# Instantiate an empty brush.
TRANSPARENT_BRUSH = Brush(None, None)
# Instantiate a solid pattern.
SOLID = Pattern()

class SVGMapWriterError(Exception):
    """Get raised for problems when writing map-svg files.
    
    Occasion when this exception is raised:
        Two layers have the same name to be used as BaseId: Name Clash 
    """


class SVGRenderer(BaseRenderer):
    """Class to render a map onto a VirtualDC.
    
    This class, derived from BaseRenderer, will render a hole
    session onto the VirtualDC to write all shapes as SVG code
    to a file.
    In opposite to other renderers it includes metadata, such as
    shape ids and classification, when rendering the shapes.
    """
    def __init__(self, dc, map, scale, offset, region,
                 resolution = 1.0, honor_visibility = 1):
        """Init SVGRenderer and call superclass init."""
        BaseRenderer.__init__(self, dc, map, scale, offset, region,
                resolution, honor_visibility)
        #
        self.factor = (abs(region[2]) + abs(region[3])) / (2.0 * 1000.0)
        self.used_baseids=[]   # needed for name clash check

    def make_point(self, x, y):
        """Return a Point object from two values."""
        return Point(x, y)
    
    def label_font(self):
        """Return the font object for the label layer.
        
        As we scale stuff, the fontsize also needs to be scaled."""
        if verbose>1:
            log("label_font() called.\n")
        return Font(fsize=12*self.factor)

    def tools_for_property(self, prop):
        """Return a pen/brush tuple build from a property object."""
        fill = prop.GetFill()
        if fill is Transparent:
            brush = TRANSPARENT_BRUSH
        else:
            brush = Brush(fill, SOLID)

        stroke = prop.GetLineColor()
        if stroke is Transparent:
            pen = TRANSPARENT_PEN
        else:
            pen = Pen(stroke, prop.GetLineWidth() * self.factor, SOLID)
        return pen, brush

    def draw_polygon_shape(self, layer, points, pen, brush):
        """Draw a polygon shape from layer with the given brush and pen

        The shape is given by points argument which is a the return
        value of the shape's Points() method. The coordinates in the
        DC's coordinate system are determined with
        self.projected_points.
        """
        points = self.projected_points(layer, points)

        if verbose > 1:
            log("drawing polygon with brush %s and pen %s\n" % 
                (str(brush), str(pen)) )
            if verbose > 2:
                log("points: %s\n" %(repr(points)))

        self.dc.SetBrush(brush)
        self.dc.SetPen(pen)
        self.dc.DrawPolygonPath(points)

    def draw_point_shape(self, layer, points, pen, brush, size=2):
        """Draw a point shape from layer with the given brush and pen

        The shape is given by points argument which is a the return
        value of the shape's Points() method. The coordinates in the
        DC's coordinate system are determined with
        self.projected_points.

        The point is drawn as a circle centered on the point.
        """
        points = self.projected_points(layer, points)
        if not points:
            return

        radius = self.factor * size
        self.dc.SetBrush(brush)
        self.dc.SetPen(pen)
        for part in points:
            for p in part:
                self.dc.DrawCircle(p.x - radius, p.y - radius,
                                    2.0 * radius)
    
    def draw_shape_layer_incrementally(self, layer):
        """Draw a shapelayer incrementally.
        """
        dc = self.dc
        brush = TRANSPARENT_BRUSH
        pen   = TRANSPARENT_PEN

        value = None
        field = None
        lc = layer.GetClassification()
        field = layer.GetClassificationColumn()
        defaultGroup = lc.GetDefaultGroup()
        table = layer.ShapeStore().Table()

        if lc.GetNumGroups() == 0:
            # There's only the default group, so we can pretend that
            # there is no field to classifiy on which makes things
            # faster since we don't need the attribute information at
            # all.
            field = None
            if verbose > 0: 
                log("layer %s has no classification\n" % layer.Title())

        # Determine which render function to use.
        useraw, draw_func, draw_func_param = \
                self.low_level_renderer(layer)
        if verbose > 0 : log("Using draw_func %s\n"%(repr(draw_func)))

        tool_cache = {}
        
        new_baseid=dc.SetBaseID(layer.title)
        if new_baseid in self.used_baseids:
            raise SVGMapWriterError(_("Clash of layer names!\n")+ \
                _("Two layers probably have the same name, try renaming one."))
        # prefix of a shape id to be unique
        self.used_baseids.append(new_baseid)
        # Titel of current layer to the groups meta informations
        dc.BeginGroup(meta={'Layer':layer.Title(), })
        # Delete all MetaData
        dc.FlushMeta()
        for shape in self.layer_shapes(layer):
            if field is None:
                group = defaultGroup
                value = group.GetDisplayText()
            else:
                value = table.ReadValue(shape.ShapeID(), field)
                group = lc.FindGroup(value)

            if not group.IsVisible():
                continue

            # Render classification
            shapeType = layer.ShapeType()
            props = group.GetProperties()

            # put meta infos into DC
            if field and value:
                dc.SetMeta({field:value, })
            # set current shape id
            dc.SetID(shape.ShapeID())

            try:
                pen, brush = tool_cache[id(group)]
            except KeyError:
                pen, brush = tool_cache[id(group)] \
                             = self.tools_for_property(group.GetProperties())

            if useraw:
                data = shape.RawData()
            else:
                data = shape.Points()

            if shapeType==SHAPETYPE_POINT:
                 draw_func(draw_func_param, data, pen, brush,
                           size = group.GetProperties().GetSize())
            else:
                 draw_func(draw_func_param, data, pen, brush)
            # compatibility
            if 0:
                yield True
        # reset shape id
        dc.SetID(-1)
        dc.SetBaseID("")
        dc.EndGroup()
    
    def draw_raster_layer(self, layer):
        """Draw the raster layer"""
        # TODO: For now we cannot draw raster layers onto our VirtualDC
        log(_("Warning: Raster layer not written as " +
              "svgexport does not support this yet!\n"))
    
    def draw_raster_data(self, data, format="BMP"):
        """Draw the raster image in data onto the DC"""
        # TODO: For now we cannot draw raster data onto our VirtualDC
        pass
    
    def RenderMap(self, selected_layer, selected_shapes):
        """Overriden to avoid automatic rendering of legend, 
        scalbar and frame.
        """
        dc = self.dc
        self.selected_layer = selected_layer
        self.selected_shapes = selected_shapes
        minx, miny, width, height = self.region
        # scale down to a size of 1000
        trans = Trafo()
        trans.Append('scale', (1000.0 / ((width + height) / 2.0)))
        #
        dc.BeginClipPath('mapclip')
        dc.DrawRectangle(0, 0, width, height)
        dc.EndClipPath()
        #
        dc.BeginGroup(meta={'Object':'map', }, clipid='mapclip', \
                transform=trans)
        self.render_map()
        dc.EndGroup()
    

class VirtualDC(XMLWriter):
    """This class imitates a DC and writes SVG instead.
    
    All shapes and graphic objects will be turned into 
    SVG elements and will be written into a file.
    Any properties, such as stroke width or stroke color,
    will be written together with the SVG elementents.
    """
    def __init__(self, file, dim=(0,0), units=''):
        """Setup some variables and objects for property collection."""
        XMLWriter.__init__(self)
        self.dim = dim
        self.units = units
        self.pen = {}
        self.brush = {}
        self.font = {}
        self.meta = {}
        self.style = {}
        # Some buffers
        self.points = []
        self.id = -1
        self.flush_meta = 1
        self.write(file)
    
    def write_indent(self, str):
        """Write a string to the file with the current indention level.
        """
        from Thuban.Model.xmlwriter import TAB
        self.file.write("%s%s" % (TAB*self.indent_level, str))
    
    def AddMeta(self, key, val):
        """Append some metadata to the array that will be
        written with the next svg-element
        """
        if key is '' or val is '':
            return
        self.meta[key] = val
    
    def SetMeta(self, pairs, flush_after=1):
        """Delete old meta informations and set the new ones."""
        self.meta = {}
        self.flush_meta = flush_after
        for key, val in pairs.items():
            self.AddMeta(key, val)
    
    def FlushMeta(self):
        """Drop collected metadata."""
        self.meta = {}

    def BeginGroup(self, **args):
        """Begin a group of elements.
        
        Possible arguments:
            meta        A list of key, value metadata pairs
            style       A list of key, value style attributes
            clipid      The ID of a clipPath definition to be
                        applied to this group
        """
        self.FlushMeta()
        # adding meta data
        if args.has_key('meta'):
            for key, val in args['meta'].items():
                self.AddMeta(key, val)
        attribs = " "
        # adding style attributes
        if args.has_key('style'):
            for key, val in args['style'].items():
                attribs += '%s="%s" ' % (key, val)
        # adding clip informations
        if args.has_key("clipid"):
            attribs += ' clip-path="url(#%s)"' % (args['clipid'],)
            # FIXME: this shouldn't be static
            attribs += ' clip-rule="evenodd"'
        if args.has_key('transform'):
            trafostr = self.parse_trafo(args['transform'])
            if trafostr:
                attribs += ' transform="%s"' % (trafostr)
        # put everything together
        self.write_indent('<g %s%s>\n' % (self.make_meta(), attribs))
        self.indent_level += 1
    
    def parse_trafo(self, trafo):
        """Examine a trafo object for asigned transformations details."""
        if not trafo:
            return ''
        retval = ''
        while trafo.Count() > 0:
            trans, coeffs = tuple(trafo.Pop())
            if isinstance(coeffs, ListType):
                retval += " %s%s" % (trans, join(coeffs, ', '))
            else: retval += " %s(%s)" % (trans, coeffs)
        # return the string
        return retval

    def EndGroup(self):
        """End a group of elements"""
        self.indent_level -= 1
        self.write_indent('</g>\n')
        self.FlushMeta()

    def BeginExport(self):
        """Start the export process and write basic document
        informations to the file.
        """
        self.write_indent('<?xml version="1.0" encoding="ISO-8859-1" '
            'standalone="yes"?>\n')
        width, height = self.dim
        self.write_indent('<svg>\n')
        self.indent_level += 1
    
    def EndExport(self):
        """End the export process with closing the SVG tag and close
        the file accessor"""
        self.indent_level -= 1
        self.write_indent('</svg>\n')
        self.close()
    
    def Close(self):
        """Close the file."""
        self.close()
    
    def BeginDrawing(self):
        """Dummy function to work with the Thuban renderers."""
        pass
        
    def EndDrawing(self):
        """Dummy function to work with the Thuban renderers."""
        pass
    
    def GetSizeTuple(self):
        """Return the dimension of this virtual canvas."""
        return self.dim
    
    def GetTextExtent(self, text):
        """Return the dimension of the given text."""
        # FIXME: find something more appropriate
        try:
            if self.font:
                return (int(self.font["font-size"]), 
                        len(text) * int(self.font["font-size"]))
            else: return (12,len(text) * 10)
        except ValueError:
            return (12,len(text) * 10)
    

    def SetBaseID(self, id):
        """Set first part of ID stored by the svg elements. Return it.

        Will be used in make_id() as first part of an XML attribute "id".
        The second part is set by SetID().
        Check comments at make_id().

        We might add an abritrary "t" for thuban if the parameter
        starts with XML, which is not allowed in XML 1.0.

        We need to ensure that all characters are okay as XML id attribute.
        As there seem no easy way in Python (today 20040925) to check
        for compliance with XML 1.0 character classes like NameChar,
        we use Python's string method isalnum() as approximation.
        (See http://mail.python.org/pipermail/xml-sig/2002-January/006981.html
        and xmlgenchar.py. To be better we would need to hold our own
        huge table of allowed unicode characters.
        FIXME if python comes with a better funcation for XML 1.0 NameChar)

        Characters that are not in our approx of NameChar get transformed
        get escaped to their hex value. 
        """
        # an ID Name shall not start with xml.
        if id[0:3].lower() == "xml":
            id = "t" + id

        self.baseid = ""
        for c in id:
            if c.isalnum() or c in ".-_":
                self.baseid += c
            else:
                self.baseid += binascii.b2a_hex(c)
        return self.baseid

    def SetID(self, id):
        """Set second part of ID stored by the svg elements.

        Will be used in make_id() as first part of an XML attribute "id".
        Only set this to positive integer numbers.
        Read comments at SetBaseID() and make_id().
        """
        self.id = id

    def SetFont(self, font):
        """Set the fontproperties to use with text elements."""
        if font is not None:
            fontname = font.GetFaceName()
            size = font.GetPointSize()
            for svgfont, pattern in fontMap.items():
                if pattern.match(fontname):
                    fontname = svgfont
                    break
            if fontname:
                self.font["font-family"] = fontname
            else: self.font["font-family"] = None
            if size:
                self.font["font-size"] = str(size)
            else: self.font["font-size"] = None
    
    def SetPen(self, pen):
        """Set the style of the pen used to draw graphics."""
        if pen is TRANSPARENT_PEN:
            self.pen = {}
        else:
            self.pen["stroke"] = pen.GetColor().hex()
            self.pen["stroke-dasharray"] = join(pen.GetDashes(), ',')
            self.pen["stroke-width"] = pen.GetWidth()
            self.pen["stroke-linejoin"] = svg_joins[pen.GetJoin()]
            self.pen["stroke-linecap"] = svg_caps[pen.GetCap()]
    
    def SetBrush(self, brush):
        """Set the fill properties."""
        if brush is TRANSPARENT_BRUSH:
            self.brush['fill'] = 'none'
        elif brush.GetPattern() is SOLID:
            self.brush['fill'] = brush.GetColor().hex()
        else: # TODO Handle Patterns
            pass
    
    def SetTextForeground(self, color):
        """Set the color of the text foreground."""
        self.font['fill'] = color.hex()
        
    def make_style(self, line=0, fill=0, font=0):
        """Build the style attribute including desired properties
        such as fill, forground, stroke, etc."""
        result = []
        # little helper function
        def append(pairs):
            for key, val in pairs.items():
                if not val in [None, '']:
                    result.append('%s:%s' % (key, val))
        #
        if line and len(self.pen) > 0:
                append(self.pen)
        if fill and len(self.brush) > 0:
                append(self.brush)
        if font and len(self.font) > 0:
                append(self.font)
        style = join(result, '; ')
        if style:
            return 'style="%s"' % (style, )
        else:
            return ''
    
    def make_meta(self, meta=None):
        """Build the meta attribute."""
        result = []
        if not meta:
            meta = self.meta
        if len(meta) is 0:
            return ''
        for key, val in meta.items():
            if not val in [None, '', 'none']:
                result.append('%s:%s' % (key, val))
        if self.flush_meta:
            self.meta = {}
        return 'meta="%s"' % (join(result, '; '))
    
    def make_id(self):
        """Return id= string for object out of currently set baseid and id.
        
        Return the empty string if no id was set.

        In an XML file each id should be unique 
        (see XML 1.0 section 3.3.1 Attribute Types, Validity constraint: ID)
        So this function should only return a unique values.
        which also coforms to the the XML "Name production" (section 3.2).

        For this it completely depends 
        on what has been set by SetBaseID() and SetID().
        Only call this function if you have called them w unique values before
        (or negative x in SetID(x) to get an empty result)
        Will raise SVGMapWriterError if SetID value cannot be converted to %d.

        A check of uniqueness in this function might be time consuming,
        because it would require to hold and search through a complete table.
        """
        if self.id < 0:
            return ''
        try:
            id= 'id="%s_%d"' % (self.baseid, self.id)
        except TypeError, inst:
            raise SVGMapWriterError(_("Internal make_id() failure: ") \
                        + repr(inst))
        return id

    def DrawEllipse(self, x, y, dx, dy):
        """Draw an ellipse."""
        elips = '<ellipse cx="%s" cy="%s" rx="%s" ry="%s" %s %s %s/>\n'
        self.write_indent(elips % (x, y, dx, dy, self.make_id(),
                self.make_style(1,1,0), self.make_meta()) )
    
    def DrawCircle(self, x, y, radius):
        """Draw a circle onto the virtual dc."""
        self.write_indent('<circle cx="%s" cy="%s" r="%s" %s %s %s/>\n' %
                (x, y, radius, self.make_id(), self.make_style(1,1,0), 
                self.make_meta()) )
    
    def DrawRectangle(self, x, y, width, height):
        """Draw a rectangle with the given parameters."""
        rect = '<rect x="%s" y="%s" width="%s" height="%s" %s %s %s/>\n'
        self.write_indent(rect %  ( x, y, width, height, self.make_id(), 
            self.make_style(1,1,0), self.make_meta()) )

    def DrawText(self, text, x, y):
        """Draw Text at the given position."""
        beginText = '<text x="%s" y="%s" %s %s %s>'
        self.write_indent(beginText %  ( x, y, self.make_id(), 
            self.make_style(0,0,1), self.make_meta()) )
        self.file.write(escape(text))
        self.file.write('</text>\n')
    
    def DrawLines(self, points):
        """Draw some points into a Buffer that will be 
        written before the next object.
        """
        self.DrawPolygonPath([points], closed=False)
    
    def DrawPolygonPath(self, polys, closed=True):
        """Draw a list of polygons or polylines as one SVG path.

        Parameter:
        polys    list of poly- gons/lines; each consisting of a list of points
        closed   Boolean; optional; Default: True
                 False will leave each subpath open thus making it polylines.
        """
        self.write_indent('<path %s ' % (self.make_style(1,1,0)))
        data = []
        for poly in polys:
            i = 0
            for point in poly:
                if i is 0:
                    data.append('\nM %s %s' % (point.x, point.y))
                    i+=1
                else:
                    # SVG 1.1 Spec 8.3.1 recommends that lines length <= 255
                    # we make a best effort in throwing in a few newlines
                    data.append('\nL %s %s' % (point.x, point.y))
            if closed:
                data.append(' Z')

        # Put everything together and write it to the file
        self.file.write('%s %s d="%s"/>\n' % (self.make_id(), 
            self.make_meta(), join(data, '') ) )

    def DrawSpline(self, points, closed=0):
        """Calculate square bezier points for an xfig approximated spline.

        DrawSpline() needs to do the same as the function of the Device Context
        of wxWidgets. Code inspection shows it uses the "approximated
        splines" of xfig <= 3.1. This can be mapped 
        on SVG's squared bezier curves,
        by doing the same calculation like wxPostScriptDC::DoDrawSpline() in
        wxWidgets src/generic/dcpsg.cpp.
        Which is derived from xfig's 3.1.4 u_draw.c(draw_open_spline()).

        And then leave out the last translation to cubic beziers
        done in the postscript code of DrawSplineSection.
        """
        self.write_indent('<path %s ' % (self.make_style(1,1,0)))
        datastr = ""

        x1=points[0].x
        y1=points[0].y

        datastr+=('M %s %s ' % (x1, y1))

        c=points[1].x
        d=points[1].y

        x3  = (x1 + c) / 2;
        y3  = (y1 + d) / 2;

        datastr+=('L %s %s ' % (x3,y3))

        for point in points[2:]:
            x2 = c
            y2 = d
            c = point.x
            d = point.y
            x3 = (x2 + c) / 2;
            y3 = (y2 + d) / 2;

            # With SVG's bezier commands, the last point becomes the next start
            # so no new L necessary
            # SVG Spec 1.1 recommends to not uses lines longer than 255 chars
            datastr+=('Q %s %s %s %s\n' % (x2,y2,x3,y3))

        datastr+=('L %s %s' % (c,d))

        self.file.write('%s %s d="%s"/>\n' % (self.make_id(), 
            self.make_meta(), datastr ) )

    def BeginClipPath(self, id):
        """Build a clipping region to draw in."""
        self.write_indent('<clipPath id="%s">\n' % id)
        self.indent_level += 1

    def EndClipPath(self):
        """End a clip path."""
        self.indent_level -= 1
        self.write_indent("</clipPath>\n")
