#!/usr/bin/env python
#coding:utf-8
# Purpose: read, create and write acad ctb files
# Created: 23.03.2010
# Copyright (C) 2010, Manfred Moitzi
# License: GPLv3

# IMPORTANT: use only standard 7-Bit ascii code

__author__ = "mozman <mozman@gmx.at>"

import sys

PYTHON3 = sys.version_info[0] > 2
if PYTHON3:
    from io import StringIO
    xrange = range
    unicode = str
    basestring = str
else:
    from StringIO import StringIO

from array import array
from struct import pack
import zlib

ENDSTYLE_BUTT = 0
ENDSTYLE_SQUARE = 1
ENDSTYLE_ROUND = 2
ENDSTYLE_DIAMOND = 3
ENDSTYLE_OBJECT = 4


JOINSTYLE_MITER = 0
JOINSTYLE_BEVEL = 1
JOINSTYLE_ROUND = 2
JOINSTYLE_DIAMOND = 3
JOINSTYLE_OBJECT = 5

FILL_STYLE_SOLID = 64
FILL_STYLE_CHECKERBOARD = 65
FILL_STYLE_CROSSHATCH = 66
FILL_STYLE_DIAMONDS = 67
FILL_STYLE_HORIZONTAL_BARS = 68
FILL_STYLE_SLANT_LEFT = 69
FILL_STYLE_SLANT_RIGHT = 70
FILL_STYLE_SQUARE_DOTS = 71
FILL_STYLE_VERICAL_BARS = 72
FILL_STYLE_OBJECT = 73

DITHERING_ON = 1 # bit coded color_policy
GRAYSCALE_ON = 2 # bit coded color_policy

AUTOMATIC = 0
OBJECT_LINEWEIGHT = 0
OBJECT_LINETYPE = 31
OBJECT_COLOR = -1
OBJECT_COLOR2 = -1006632961

STYLE_COUNT = 255

DEFAULT_LINE_WEIGHTS = [
 0.00, # 0
 0.05, # 1
 0.09, # 2
 0.10, # 3
 0.13, # 4
 0.15, # 5
 0.18, # 6
 0.20, # 7
 0.25, # 8
 0.30, # 9
 0.35, # 10
 0.40, # 11
 0.45, # 12
 0.50, # 13
 0.53, # 14
 0.60, # 15
 0.65, # 16
 0.70, # 17
 0.80, # 18
 0.90, # 19
 1.00, # 20
 1.06, # 21
 1.20, # 22
 1.40, # 23
 1.58, # 24
 2.00, # 25
 2.11, # 26
]

def color_name(index):
    return 'Color_%d' % (index+1)

def get_bool(value):
    if isinstance(value, basestring):
        upperstr = value.upper()
        if upperstr == 'TRUE':
            value = True
        elif upperstr == 'FALSE':
            value = False
        else:
            raise ValueError("Unknown bool value '%s'." % str(value))
    return value

class UserStyle(object):
    def __init__(self, index, init_dict={}, parent=None):
        self.parent = parent
        self.index = int(index)
        self.description = unicode(init_dict.get('description', ""))
        # do not set _color, _mode_color or _color_policy directly
        # use set_color() method, and the properties dithering and grayscale
        self._color = int(init_dict.get('color', OBJECT_COLOR))
        if self._color != OBJECT_COLOR:
            self._mode_color = int(init_dict.get('mode_color', self._color))
        self._color_policy = int(init_dict.get('color_policy', DITHERING_ON))
        self.physical_pen_number = int(init_dict.get('physical_pen_number', AUTOMATIC))
        self.virtual_pen_number = int(init_dict.get('virtual_pen_number', AUTOMATIC))
        self.screen = int(init_dict.get('screen', 100))
        self.linepattern_size = float(init_dict.get('linepattern_size', 0.5))
        self.linetype = int(init_dict.get('linetype', OBJECT_LINETYPE)) # 0 .. 30
        self.adaptive_linetype = get_bool(init_dict.get('adaptive_linetype', True))
        self.lineweight = int(init_dict.get('lineweight', OBJECT_LINEWEIGHT))
        self.end_style = int(init_dict.get('end_style', ENDSTYLE_OBJECT))
        self.join_style = int(init_dict.get('join_style', JOINSTYLE_OBJECT))
        self.fill_style = int(init_dict.get('fill_style', FILL_STYLE_OBJECT))

    def set_color(self, red, green, blue):
        """Set color as rgb-tuple."""
        self._mode_color = mode_color2int(red, green, blue)
        # when defining a user-color, <mode_color> represents the real truecolor
        # as rgb-tuple with the magic number 0xC2 as highest byte, the <color>
        # value calculated for a user-color is not a rgb-tuple and has the magic
        # number 0xC3 (sometimes), I set for <color> the same value a for
        # <mode_color>, because Autocad corrects the <color> value by itself.
        self._color = self._mode_color

    def set_object_color(self):
        """Set color to object color."""
        self._color = OBJECT_COLOR
        self._mode_color = OBJECT_COLOR

    def set_lineweight(self, lineweight):
        """Set lineweight. Use 0.0 to set lineweight by object.

        lineweight in mm! not the lineweight index
        """
        self.lineweight = self.parent.get_lineweight_index(lineweight)

    def get_lineweight(self):
        """Returns the lineweight in millimeters.

        returns 0.0 for: use object lineweight
        """
        return self.parent.lineweights[self.lineweight]

    def has_object_color(self):
        """True if style has object color."""
        return self._color == OBJECT_COLOR or \
               self._color == OBJECT_COLOR2

    def get_color(self):
        """Get style color as rgb-tuple or None if style has object color."""
        if self.has_object_color():
            return None # object color
        else:
            return int2color(self._mode_color)[:3]

    def get_dxf_color_index(self):
        return self.index+1


    def get_dithering(self):
        return bool(self._color_policy & DITHERING_ON)

    def set_dithering(self, status):
        if status :
            self._color_policy |= DITHERING_ON
        else:
            self._color_policy &= ~DITHERING_ON
    dithering = property(get_dithering, set_dithering)

    def get_grayscale(self):
        return bool(self._color_policy & GRAYSCALE_ON)

    def set_grayscale(self, status):
        if status :
            self._color_policy |= GRAYSCALE_ON
        else:
            self._color_policy &= ~GRAYSCALE_ON
    grayscale = property(get_grayscale, set_grayscale)

    def write(self, fileobj):
        """Write style data to file-like object <fileobj>."""
        index = self.index
        fileobj.write(' %d{\n' % index)
        fileobj.write('  name="%s\n' % color_name(index))
        fileobj.write('  localized_name="%s\n' % color_name(index))
        fileobj.write('  description="%s\n' % self.description)
        fileobj.write('  color=%d\n' % self._color)
        if self._color != OBJECT_COLOR:
            fileobj.write('  mode_color=%d\n' % self._mode_color)
        fileobj.write('  color_policy=%d\n' % self._color_policy)
        fileobj.write('  physical_pen_number=%d\n' % self.physical_pen_number)
        fileobj.write('  virtual_pen_number=%d\n' % self.virtual_pen_number)
        fileobj.write('  screen=%d\n' % self.screen)
        fileobj.write('  linepattern_size=%s\n' % str(self.linepattern_size))
        fileobj.write('  linetype=%d\n' % self.linetype)
        fileobj.write('  adaptive_linetype=%s\n' % str(bool(self.adaptive_linetype)).upper())
        fileobj.write('  lineweight=%s\n' % str(self.lineweight))
        fileobj.write('  fill_style=%d\n' % self.fill_style)
        fileobj.write('  end_style=%d\n' % self.end_style)
        fileobj.write('  join_style=%d\n' % self.join_style)
        fileobj.write(' }\n')

class UserStyles(object):
    """UserStyle container"""

    def __init__(self, description="", scale_factor=1.0, apply_factor=False):
        self.description = description
        self.scale_factor = scale_factor
        self.apply_factor = apply_factor

        # set custom_line... to 1 for showing lineweights in inch in the Autocad
        # ctb editor window, but lineweights are always defined in mm
        self.custom_lineweight_display_units = 0
        self.styles = [None] * (STYLE_COUNT+1)
        self.lineweights = array('f', DEFAULT_LINE_WEIGHTS)
        self.set_default_styles()

    def set_default_styles(self):
        for index in xrange(STYLE_COUNT):
            self._set_style(UserStyle(index))

    @staticmethod
    def check_color_index(dxf_color_index):
        if 0 < dxf_color_index < 256:
            return dxf_color_index
        raise IndexError('color index has to be in the range [1 .. 255].')

    def iter_styles(self):
        return (style for style in self.styles[1:])

    def _set_style(self, style):
        style.parent = self
        self.styles[style.get_dxf_color_index()] = style

    def set_style(self, dxf_color_index, init_dict={}):
        """Set <dxf_color_index> to new attributes defined in init_dict.
        """
        dxf_color_index = self.check_color_index(dxf_color_index)
        # ctb table index is dxf_color_index - 1
        # ctb table starts with index 0, where dxf_color_index=0 means BYBLOCK
        style = UserStyle(dxf_color_index-1, init_dict)
        self._set_style(style)
        return style

    def get_style(self, dxf_color_index):
        """Get style for <dxf_color_index>.
        """
        dxf_color_index = self.check_color_index(dxf_color_index)
        return self.styles[dxf_color_index]

    # interface for dxfwrite.std.DXFColorIndex
    def get_color(self, dxf_color_index):
        """Get rgb-color-tuple for <dxf_color_index> or None if not specified.
        """
        style = self.get_style(dxf_color_index)
        return style.get_color()

    # interface for dxfwrite.std.DXFLineweight
    def get_lineweight(self, dxf_color_index):
        """Returns the assigned lineweight for <dxf_color_index> in mm."""
        style = self.get_style(dxf_color_index)
        lineweight = style.get_lineweight()
        if lineweight == 0.0:
            return None
        else:
            return lineweight

    def get_lineweight_index(self, lineweight):
        """Get index of lineweight in the lineweight table or append lineweight
        to lineweight table.
        """
        try:
            return self.lineweights.index(lineweight)
        except ValueError:
            self.lineweights.append(lineweight)
            return len(self.lineweights)-1

    def set_table_lineweight(self, index, weight):
        """Index is the lineweight table index, not the dxf color index.

        :param int index: lineweight table index = UserStyle.lineweight
        :param float weight: in millimeters
        """
        try:
            self.lineweights[index] = weight
            return index
        except IndexError:
            self.lineweights.append(weight)
            return len(self.lineweights)-1

    def get_table_lineweight(self, index):
        """Returns lineweight in millimeters.

        returns 0.0 for: use object lineweight

        :param int index: lineweight table index = UserStyle.lineweight
        """
        return self.lineweights[index]

    def save(self, filename):
        """Save ctb-file to <filename>."""
        fileobj = open(filename, 'wb')
        self.write(fileobj)
        fileobj.close()

    def write(self, fileobj):
        """Create and compress the ctb-file to <fileobj>."""
        memfile = StringIO()
        self.write_content(memfile)
        memfile.write(chr(0)) # end of file
        body = memfile.getvalue()
        memfile.close()
        self._compress(fileobj, body)

    def write_content(self, fileobj):
        """Write the ctb-file to <fileobj>."""
        self._write_header(fileobj)
        self._write_aci_table(fileobj)
        self._write_ctb_plot_styles(fileobj)
        self._write_lineweights(fileobj)

    def _write_header(self, fileobj):
        """Write header values of ctb-file to <fileobj>."""
        fileobj.write('description="%s\n' % self.description)
        fileobj.write('aci_table_available=TRUE\n')
        fileobj.write('scale_factor=%.1f\n' % self.scale_factor)
        fileobj.write('apply_factor=%s\n' % str(self.apply_factor).upper())
        fileobj.write('custom_lineweight_display_units=%s\n' % str(
            self.custom_lineweight_display_units))

    def _write_aci_table(self, fileobj):
        """Write autocad color index table to ctb-file <fileobj>."""
        fileobj.write('aci_table{\n')
        for style in self.iter_styles():
            index = style.index
            fileobj.write(' %d="%s\n' % (index, color_name(index)))
        fileobj.write('}\n')

    def _write_ctb_plot_styles(self, fileobj):
        """Write user styles to ctb-file <fileobj>."""
        fileobj.write('plot_style{\n')
        for style in self.iter_styles():
            style.write(fileobj)
        fileobj.write('}\n')

    def _write_lineweights(self, fileobj):
        """Write custom lineweights table to ctb-file <fileobj>."""
        fileobj.write('custom_lineweight_table{\n')
        for index, weight in enumerate(self.lineweights):
            fileobj.write(' %d=%.2f\n' % (index, weight))
        fileobj.write('}\n')

    def parse(self, text):
        """Parse and get values of plot styles from <text>."""
        def set_lineweights(lineweights):
            if lineweights is None:
                return
            self.lineweights = array('f', [0.0] * len(lineweights))
            for key, value in lineweights.items():
                self.lineweights[int(key)] = float(value)

        def set_styles(styles):
            for index, style in styles.items():
                style = UserStyle(index, style)
                self._set_style(style)

        parser = CtbParser(text)
        self.description = parser.get('description', "")
        self.scale_factor = float(parser.get('scale_factor', 1.0))
        self.apply_factor = get_bool(parser.get('apply_factor', True))
        self.custom_lineweight_display_units = int(
            parser.get('custom_lineweight_display_units', 0))
        set_lineweights(parser.get('custom_lineweight_table', None))
        set_styles(parser.get('plot_style', {}))


    def _compress(self, fileobj, body):
        """Compress ctb-file-body and write it to <fileobj>."""
        def writestr(s):
            if PYTHON3:
                fileobj.write(s.encode())
            else:
                fileobj.write(s)
        if PYTHON3:
            body = body.encode()
        comp_body = zlib.compress(body)
        adler_chksum = zlib.adler32(comp_body)
        writestr('PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec')
        fileobj.write(pack('LLL', adler_chksum, len(body), len(comp_body)))
        fileobj.write(comp_body)

def read(fileobj):
    """Read a ctb-file from the file-like object <fileobj>.
    Returns a UserStyle object.
    """
    content = _decompress(fileobj)
    if PYTHON3:
        content = content.decode()
    styles = UserStyles()
    styles.parse(content)
    return styles

def load(filename):
    """Load the ctb-file <filename>."""
    fileobj = open(filename, 'rb')
    ctbfile = read(fileobj)
    fileobj.close()
    return ctbfile

def _decompress(fileobj):
    """Read and decompress the file content of the file-like object <fileobj>."""
    content = fileobj.read()
    text = zlib.decompress(content[60:])
    return text[:-1] # truncate trailing \nul

class CtbParser(object):
    """A very simple ctb-file parser. Ctb-files are created by programms, so the
    file structure should be correct in the most cases.
    """
    def __init__(self, text):
        """Construtor

        text -- ctb content as string
        """
        self.data = {}
        for element, value in CtbParser.iteritems(text):
            self.data[element] = value

    @staticmethod
    def iteritems(text):
        """iterate over all first level (start at col 0) elements"""
        def get_name(line_index):
            """Get element name of line <line_index>.
            """
            line = lines[line_index]
            if line.endswith('{'): # start of a list like 'plot_style{'
                name = line[:-1]
            else: # simple name=value line
                name = line.split('=', 1)[0]
            return name.strip()

        def get_list(line_index):
            """Get list of elements enclosed by { }.

            lineweigths, plot_styles, aci_table
            """
            def end_of_list():
                return lines[line_index].endswith('}')

            data = dict()
            while not end_of_list():
                name = get_name(line_index)
                line_index, value = get_value(line_index) # get value or sub-list
                data[name] = value
            return line_index+1, data # skip '}' - end of list

        def get_value(line_index):
            """Get value of line <line_index> or the list that starts in line
            <line_index>.
            """
            line = lines[line_index]
            if line.endswith('{'): # start of a list
                line_index, value = get_list(line_index+1)
            else: # it's a simple name=value line
                value = line.split('=', 1)[1]
                value = value.lstrip('"') # strings look like this: name="value
                line_index += 1
            return line_index, value

        def skip_empty_lines(line_index):
            while line_index < len(lines) and len(lines[line_index]) == 0:
                line_index += 1
            return line_index

        lines = text.split('\n')
        line_index = 0
        while line_index < len(lines):
            name = get_name(line_index)
            line_index, value = get_value(line_index)
            yield(name, value)
            line_index = skip_empty_lines(line_index)

    def get(self, name, default):
        return self.data.get(name, default)

def int2color(color):
    """Convert color integer value from ctb-file to rgb-tuple plus a magic number.
    """
    magic = (color & 0xff000000) >> 24 # is 0xc3 or 0xc2 don't know what this value means
    red = (color & 0xff0000) >> 16
    green = (color & 0xff00) >> 8
    blue = color & 0xff
    return (red, green, blue, magic)

def mode_color2int(red, green, blue, magic=0xc2):
    """Convert rgb-tuple to an int value."""
    return -color2int(red, green, blue, magic)

def color2int(red, green, blue, magic):
    """Convert rgb-tuple to an int value."""
    return -((magic << 24) + (red << 16) + (green << 8) + blue) & 0xffffffff
