Shoobx/shoobx.rml2odt

View on GitHub
src/shoobx/rml2odt/stylesheet.py

Summary

Maintainability
D
3 days
Test Coverage
##############################################################################
#
# Copyright (c) 2017 Shoobx, Inc.
# All Rights Reserved.
#
# This software is subject to the provisions of the Zope Public License,
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
# FOR A PARTICULAR PURPOSE.
#
##############################################################################
"""Style Related Element Processing
"""
import copy
from collections import defaultdict

import lazy
import odf.style
import odf.text
import reportlab.lib.enums
import reportlab.lib.styles
import reportlab.platypus
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT, TA_RIGHT
from z3c.rml import SampleStyleSheet, attr, directive, special
from z3c.rml import stylesheet as rml_stylesheet

RML2ODT_ALIGNMENTS = {
    TA_LEFT: 'left',
    TA_CENTER: 'center',
    TA_RIGHT: 'right',
    TA_JUSTIFY: 'justify',
}


def pt(pt):
    return '%spt' % pt


def hexColor(color):
    if color is None:
        return color
    return '#%s' % color.hexval()[2:]


class Initialize(directive.RMLDirective):
    signature = rml_stylesheet.IInitialize
    factories = {
        'name': special.Name,
        'alias': special.Alias,
    }


FONT_MAP = {
    'symbol': 'Symbol',
    'zapfdingbats': 'ZapfDingbats',
    'helvetica': 'Arial',
    'times': 'Times New Roman',
    'courier': 'Courier',
    'garamond': 'Adobe Garamond Pro',
}


def rmlFont2odfFont(font):
    # Maps between RML/Postscript font names and ODT/LibreOffice names
    name = font.lower().split('-')[0]
    return FONT_MAP.get(name, font)


def registerParagraphStyle(doc, name, rmlStyle):
    if 'style.' in name:
        name = name[6:]

    odtStyle = odf.style.Style(name=name, family='paragraph')
    doc.automaticstyles.addElement(odtStyle)

    # Paragraph Properties
    paraProps = odf.style.ParagraphProperties()
    odtStyle.addElement(paraProps)
    if name == "sig-small-logo":
        paraProps.setAttribute('lineheight', "0.14in")
    else:
        paraProps.setAttribute('linespacing',
                               pt(rmlStyle.leading - rmlStyle.fontSize))
    if name == "Small":
        paraProps.setAttribute('textalign', 'left')
    else:
        paraProps.setAttribute('textalign',
                               RML2ODT_ALIGNMENTS[rmlStyle.alignment])
    if rmlStyle.justifyLastLine:
        paraProps.setAttribute(
            'textalignlast', 'justify')
    paraProps.setAttribute(
        'textindent', pt(rmlStyle.firstLineIndent))
    paraProps.setAttribute(
        'widows', int(rmlStyle.allowWidows))
    paraProps.setAttribute(
        'orphans', int(rmlStyle.allowOrphans))
    paraProps.setAttribute(
        'marginleft', pt(rmlStyle.leftIndent))
    paraProps.setAttribute(
        'marginright', pt(rmlStyle.rightIndent))
    paraProps.setAttribute(
        'margintop', pt(rmlStyle.spaceBefore))
    paraProps.setAttribute(
        'marginbottom', pt(rmlStyle.spaceAfter))

    if rmlStyle.backColor is not None:
        paraProps.setAttribute(
            'backgroundcolor', '#' + rmlStyle.backColor.hexval()[2:])

    if rmlStyle.borderPadding is not None:

        paraProps.setAttribute('padding', '%spt' % rmlStyle.borderPadding)

    if rmlStyle.borderWidth:
        if rmlStyle.borderColor:
            color = rmlStyle.borderColor.hexval()[2:]
        else:
            # Default to black if no color is specified. I can't find any
            # docs on how attributes like this are supposed to work, so...
            color = "000000"
        paraProps.setAttribute('border', "{}pt solid #{}".format(
            rmlStyle.borderWidth, color))

    # reportlab styles doesn't have guaranteed attributes, so we need hasattr
    if getattr(rmlStyle, 'keepWithNext', False):
        paraProps.setAttribute('keepwithnext', 'always')

    # Text Properties
    textProps = odf.style.TextProperties()
    odtStyle.addElement(textProps)

    if rmlStyle.fontName is not None:
        flag = rmlStyle.fontName.find('-')
        if flag != -1:
            transform = rmlStyle.fontName[flag + 1:]
            if transform == 'Italic':
                textProps.setAttribute('fontstyle', 'italic')
            elif transform == 'Bold':
                textProps.setAttribute('fontweight', 'bold')
            elif transform == 'BoldItalic':
                textProps.setAttribute('fontweight', 'bold')
                textProps.setAttribute('fontstyle', 'italic')

        odf_font_name = rmlFont2odfFont(rmlStyle.fontName)
        doc.fontfacedecls.addElement(
            odf.style.FontFace(
                name=odf_font_name,
                fontfamily=odf_font_name))
        textProps.setAttribute('fontname', odf_font_name)
    textProps.setAttribute('fontsize', rmlStyle.fontSize)
    textProps.setAttribute('texttransform', rmlStyle.textTransform)

    if getattr(rmlStyle, 'underline', False):
        textProps.setAttribute('textunderlinetype', 'single')
    if rmlStyle.textColor is not None:
        textProps.setAttribute('color', '#' + rmlStyle.textColor.hexval()[2:])
    if rmlStyle.backColor is not None:
        textProps.setAttribute(
            'backgroundcolor', '#' + rmlStyle.backColor.hexval()[2:])

    return odtStyle


class ParagraphStyle(directive.RMLDirective):
    signature = rml_stylesheet.IParagraphStyle

    def adjustAttributeValues(self, style, parentName):
        try:
            parentElem = self.parent.parent.document.getStyleByName(
                str(parentName))
        except AssertionError:
            # The style doesn't exist in the document
            return style

        paraProps = [x for x in parentElem.childNodes
                     if 'paragraph' in x.tagName][0]
        textProps = [x for x in parentElem.childNodes
                     if 'text' in x.tagName][0]

        textPropsMapper = {
            'font-name': 'fontName',
            'text-transform': 'textTransform',
            'font-size': 'fontSize'
        }
        paraPropsMapper = {
            # 'margin-right':'rightIndent',
            # 'line-spacing':,
            # 'margin-top':'spaceBefore',
            # 'text-align':'alignment',
            'orphans': 'allowOrphans',
            # 'margin-left':'leftIndent',
            # 'margin-bottom':'spaceAfter',
            'padding': 'borderPadding',
            # 'text-indent':'firstLineIndent',
            'widows': 'allowWidows'
        }

        for attrib in textProps.attributes:
            desiredAttribute = str(attrib[1])
            value = textProps.attributes[attrib]
            try:
                value = float(value)
            except ValueError:
                value = value
            if desiredAttribute in textPropsMapper:
                setattr(style, textPropsMapper[desiredAttribute], value)

        for attrib in paraProps.attributes:
            desiredAttribute = str(attrib[1])
            value = paraProps.attributes[attrib]
            try:
                value = float(value)
            except ValueError:
                value = value
            if desiredAttribute in paraPropsMapper:
                setattr(style, paraPropsMapper[desiredAttribute], value)
        return style

    def process(self):
        kwargs = dict(self.getAttributeValues())
        parent = kwargs.pop(
            'parent', SampleStyleSheet['Normal'])
        name = kwargs.pop('name')
        style = copy.deepcopy(parent)
        style.name = name[6:] if name.startswith('style.') else name
        document = self.parent.parent.document
        if name == 'Normal':
            defaultNormalStyle = self.parent.parent.document.getStyleByName(
                'Normal')
            self.parent.parent.document.automaticstyles.removeChild(
                defaultNormalStyle)

        style = self.adjustAttributeValues(style, parent.name)

        for attrName, attrValue in kwargs.items():
            setattr(style, attrName, attrValue)
        registerParagraphStyle(document, name, style)
        attr.getManager(self).styles[name] = style


class SpanStyle(ParagraphStyle):
    signature = rml_stylesheet.ISpanStyle

    def process(self):
        kwargs = dict(self.getAttributeValues())
        name = kwargs.get('name')
        super().process()
        self.parent.parent.document.getStyleByName(
            str(name)
        ).setAttribute('family', 'text')





class TableStyleCommand(directive.RMLDirective):
    collectorKey = None

    def process(self):
        self.parent.collector[self.collectorKey].append(self)

    @lazy.lazy
    def _cachedAttributeValues(self):
        return dict(self.getAttributeValues())

    def getStyleProps(self):
        attrs = self._cachedAttributeValues

        result = dict(
            start=attrs['start'],
            stop=attrs['stop'],
            textProps={},
            paraProps={},
            cellProps={},
        )
        return result


class BlockFont(TableStyleCommand):
    signature = rml_stylesheet.IBlockFont
    collectorKey = 'blockFont'

    def getStyleProps(self):
        result = super().getStyleProps()

        attrs = self._cachedAttributeValues
        for key, val in attrs.items():
            if key == 'name':
                result['textProps']['fontname'] = val
            elif key == 'size':
                result['textProps']['fontsize'] = pt(val)
            elif key == 'leading':
                result['paraProps']['linespacing'] = pt(val)

        return result


class BlockLeading(TableStyleCommand):
    signature = rml_stylesheet.IBlockLeading
    collectorKey = 'blockLeading'

    def getStyleProps(self):
        result = super().getStyleProps()

        attrs = self._cachedAttributeValues
        result['paraProps']['linespacing'] = pt(attrs['length'])
        return result


class BlockTextColor(TableStyleCommand):
    signature = rml_stylesheet.IBlockTextColor
    collectorKey = 'blockTextColor'

    def getStyleProps(self):
        result = super().getStyleProps()

        attrs = self._cachedAttributeValues
        result['textProps']['color'] = hexColor(attrs['colorName'])
        return result


def convertAlignment(value):
    value = value.lower()
    if value == 'decimal':
        # ODT has no decimal alignment, our best chance is right
        value = 'right'
    if value == 'centre':
        value == 'center'
    return value


class BlockAlignment(TableStyleCommand):
    signature = rml_stylesheet.IBlockAlignment
    collectorKey = 'blockAlignment'

    def getStyleProps(self):
        result = super().getStyleProps()

        attrs = self._cachedAttributeValues
        result['paraProps']['textalign'] = convertAlignment(attrs['value'])

        return result


class BlockLeftPadding(TableStyleCommand):
    signature = rml_stylesheet.IBlockLeftPadding
    collectorKey = 'blockLeftPadding'

    def getStyleProps(self):
        result = super().getStyleProps()

        attrs = self._cachedAttributeValues
        result['cellProps']['paddingleft'] = pt(attrs['length'])
        return result


class BlockRightPadding(TableStyleCommand):
    signature = rml_stylesheet.IBlockRightPadding
    collectorKey = 'blockRightPadding'

    def getStyleProps(self):
        result = super().getStyleProps()

        attrs = self._cachedAttributeValues
        result['cellProps']['paddingright'] = pt(attrs['length'])
        return result


class BlockBottomPadding(TableStyleCommand):
    signature = rml_stylesheet.IBlockBottomPadding
    collectorKey = 'blockBottomPadding'

    def getStyleProps(self):
        result = super().getStyleProps()

        attrs = self._cachedAttributeValues
        result['cellProps']['paddingbottom'] = pt(attrs['length'])
        return result


class BlockTopPadding(TableStyleCommand):
    signature = rml_stylesheet.IBlockTopPadding
    collectorKey = 'blockTopPadding'

    def getStyleProps(self):
        result = super().getStyleProps()

        attrs = self._cachedAttributeValues
        result['cellProps']['paddingtop'] = pt(attrs['length'])
        return result


class BlockBackground(TableStyleCommand):
    signature = rml_stylesheet.IBlockBackground
    collectorKey = 'blockBackground'

    def getStyleProps(self):
        result = super().getStyleProps()

        attrs = self._cachedAttributeValues
        if 'colorName' in attrs:
            result['cellProps']['backgroundcolor'] = \
                hexColor(attrs['colorName'])
        if 'colorsByRow' in attrs:
            result['cellProps']['backgroundcolors'] = \
                [hexColor(cn) for cn in attrs['colorsByRow']]
        if 'colorsByCol' in attrs:
            result['cellProps']['backgroundcolors'] = \
                [hexColor(cn) for cn in attrs['colorsByCol']]

        return result

    def process(self):
        attrs = self._cachedAttributeValues
        # since colorsByRow and colorsByCol should act as
        # blockRowBackground and blockColBackground, let's do a tag translation
        # here, e.g.:
        # <blockBackground colorsByRow="0xD0FFD0;None"
        #   start="0,1" stop="-1,-1"/>
        if 'colorsByRow' in attrs:
            self.collectorKey = 'blockRowBackground'
        if 'colorsByCol' in attrs:
            self.collectorKey = 'blockColBackground'
        return super().process()


class BlockRowBackground(TableStyleCommand):
    signature = rml_stylesheet.IBlockRowBackground
    collectorKey = 'blockRowBackground'

    def getStyleProps(self):
        result = super().getStyleProps()

        attrs = self._cachedAttributeValues
        result['cellProps']['backgroundcolors'] = [
            hexColor(cn) for cn in attrs['colorNames']]
        return result


class BlockColBackground(TableStyleCommand):
    signature = rml_stylesheet.IBlockColBackground
    collectorKey = 'blockColBackground'

    def getStyleProps(self):
        result = super().getStyleProps()

        attrs = self._cachedAttributeValues
        result['cellProps']['backgroundcolors'] = [
            hexColor(cn) for cn in attrs['colorNames']]
        return result


class BlockValign(TableStyleCommand):
    signature = rml_stylesheet.IBlockValign
    collectorKey = 'blockValign'

    def getStyleProps(self):
        result = super().getStyleProps()

        attrs = self._cachedAttributeValues
        result['cellProps']['verticalalign'] = attrs['value'].lower()
        return result


class BlockSpan(TableStyleCommand):
    signature = rml_stylesheet.IBlockSpan
    collectorKey = 'blockSpan'


def convertLineStyle(attrs):
    # ODT line styles discovered by trying:
    # solid, dotted, dashed, fine-dashed, dash-dot, dash-dot-dot,
    # double, double-thin
    # there are plenty left
    if attrs.get('count') == 2:
        style = 'double'
    else:
        style = 'solid'  # by default
        dash = attrs.get('dash')
        if dash:
            # ohwell do some magic, there's no such custom border in ODT
            if len(dash) == 2:
                if dash[0] == dash[1]:
                    if dash[0] in (1, 2):
                        style = 'dotted'
                    elif dash[0] in (3, 4):
                        style = 'fine-dashed'
                    else:
                        style = 'dashed'
                else:
                    style = 'dash-dot'
            else:
                # what? dash-dot-dot?
                pass

    parts = []
    if attrs.get('thickness'):
        parts.append(pt(attrs.get('thickness')))
    else:
        parts.append('1pt')
    parts.append(style)
    if attrs.get('colorName'):
        parts.append(hexColor(attrs['colorName']))

    return ' '.join(parts)


class LineStyle(TableStyleCommand):
    signature = rml_stylesheet.ILineStyle
    collectorKey = 'lineStyle'

    kindMap = {
        'LINEBELOW': 'borderbottom',
        'LINEABOVE': 'bordertop',
        'LINEBEFORE': 'borderleft',
        'LINEAFTER': 'borderright',
    }

    def getStyleProps(self):
        result = super().getStyleProps()

        attrs = self._cachedAttributeValues
        result['kind'] = attrs['kind']
        stylestr = convertLineStyle(attrs)
        if attrs['kind'] in self.kindMap:
            # catering a simple ODT setAttribute loop
            result['cellProps'][self.kindMap[attrs['kind']]] = stylestr
        else:
            # otherwise there will be special processing in
            # BlockTable.getStyleMap
            result['border'] = stylestr
        return result


class BlockTableStyle(directive.RMLDirective):
    signature = rml_stylesheet.IBlockTableStyle

    factories = {
        'blockFont': BlockFont,
        'blockTextColor': BlockTextColor,
        'blockLeading': BlockLeading,
        'blockAlignment': BlockAlignment,
        'blockValign': BlockValign,
        'blockLeftPadding': BlockLeftPadding,
        'blockRightPadding': BlockRightPadding,
        'blockBottomPadding': BlockBottomPadding,
        'blockTopPadding': BlockTopPadding,
        'blockBackground': BlockBackground,
        'lineStyle': LineStyle,
        'blockSpan': BlockSpan,

        # z3c.rml only features, alternating row/col colors,
        # no start/stop attributes
        'blockRowBackground': BlockRowBackground,
        'blockColBackground': BlockColBackground,
    }

    def process(self):
        self.collector = defaultdict(list)

        # Create Style
        manager = attr.getManager(self)
        attrs = dict(self.getAttributeValues())
        self.styleID = attrs.pop('id')
        manager.styles[self.styleID] = self

        self.processSubDirectives()


BULLETS = {
    'bulletchar': '\u2022',
    'circle': '\u25cf',
    'square': '\u25AA',
    'diamond': '\u2B29',
    'darrowhead': '\u2304',
    'rarrowhead': '\u27a4'
}


def registerListStyle(doc, name, rmlStyle, attributes=None, ulol=None):
    """Registers an rmlStyle as ODF styles

    rmlStyles have information both for ordered and unordered lists,
    ODF styles do not, so we need to register two different, but similar lists.
    """
    if ulol is None:
        # Register both the unordered and ordered lists. odf seem to only
        # include the ones actually used anyway.
        registerListStyle(doc, name, rmlStyle, attributes=attributes,
                          ulol='ul')
        registerListStyle(doc, name, rmlStyle, attributes=attributes,
                          ulol='ol')
        return

    name = f'{name}-{ulol}'

    if attributes is None:
        attributes = {}
    start = attributes.get('start', getattr(rmlStyle, 'start', 1))
    if isinstance(start, int):
        bulletType = None
    else:
        bulletType = start

    numType = attributes.get('bulletType',
                             getattr(rmlStyle, 'bulletType', None))
    bulletFormat = attributes.get('bulletFormat',
                                  getattr(rmlStyle, 'bulletFormat', None))
    bulletDedent = attributes.get('bulletDedent',
                                  getattr(rmlStyle, 'bulletDedent', 'auto'))

    if bulletDedent is None or bulletDedent == 'auto':
        bulletDedent = 18

    if isinstance(bulletDedent, str):
        units = {'in': reportlab.lib.units.inch,
                 'cm': reportlab.lib.units.cm,
                 'mm': reportlab.lib.units.mm,
                 'pt': 1}

        bulletDedent = float(bulletDedent[:-2]) * units[bulletDedent[-2:]]

    odtStyle = odf.text.ListStyle(name=name)

    # Add the level properties:
    for level in range(1, 11):

        # Declare properties of the list style
        listProps = odf.style.ListLevelProperties()
        listProps.setAttribute('listlevelpositionandspacemode',
                               'label-alignment')
        if getattr(rmlStyle, 'bulletFontName', None) is not None:
            odf_font_name = rmlFont2odfFont(rmlStyle.bulletFontName)

            if odf_font_name not in [x.getAttribute('name')
                                     for x in doc.fontfacedecls.childNodes]:
                doc.fontfacedecls.addElement(
                    odf.style.FontFace(
                        name=odf_font_name,
                        fontfamily=odf_font_name))
                listProps.setAttribute('fontname', odf_font_name)

        level_indent = (18 * (level-1)) + bulletDedent
        label_align = odf.style.ListLevelLabelAlignment(
            labelfollowedby="listtab",
            listtabstopposition="%spt" % level_indent,
            textindent="-%spt" % bulletDedent,
            marginleft="%spt" % level_indent)
        listProps.appendChild(label_align)

        # Make the number (ol) style:
        if ulol == 'ol':
            # if numType is just one character (or None)
            # means some sort of number
            if bulletFormat is not None:
                pre, post = bulletFormat.split('%s')
            else:
                pre = post = ''

            if numType and numType.lower() not in '1ai':
                # ODF doesn't support fancy formats like '1st' or 'First'.
                # Make a number format that is empty.
                odtStyle.fancy_numbering = numType
                odtStyle.post = post
                odtStyle.pre = pre
                post = pre = ''
                numType = ''

            lvl_style = odf.text.ListLevelStyleNumber(
                level=level,
                numsuffix=post,
                numprefix=pre,
                numformat=numType,
                startvalue=start,
            )
        else:
            # bullet / ul style
            retrievedBullet = BULLETS.get(bulletType)
            if bulletType and retrievedBullet is None:
                # The bullet is a text, such as "RESOLVED:" etc
                lvl_style = odf.text.ListLevelStyleNumber(
                    level=level,
                    # A bug in the DOCX conversion removes the first character.
                    # A space first in the prefix and a space as suffix
                    # fixes that.
                    numprefix=' ' + bulletType,
                    numsuffix=' ',
                    numformat='')
            else:
                # Make the bullet (ul) style:
                if retrievedBullet is None:
                    retrievedBullet = BULLETS['bulletchar']
                lvl_style = odf.text.ListLevelStyleBullet(
                    bulletchar=retrievedBullet,
                    level=level,
                    bulletrelativesize='75%')

        lvl_style.addElement(listProps)
        odtStyle.addElement(lvl_style)

    pstyle = odf.style.Style(name='P%s' % name,
                             parentstylename='Standard',
                             liststylename=name,
                             family='paragraph'
                             )
    doc.automaticstyles.addElement(pstyle)

    # Add the style to the doc
    doc.automaticstyles.addElement(odtStyle)


class ListStyle(directive.RMLDirective):
    signature = rml_stylesheet.IListStyle

    def process(self):
        kwargs = dict(self.getAttributeValues())
        parent = kwargs.pop('parent', None)
        if parent is not None:
            # Get the style attribs from the parent
            style_attrs = dict(self.getAttributeValues(includeMissing=True))
            pkwargs = {att: getattr(parent, att)
                       for att in style_attrs if
                       hasattr(parent, att)}
            # Override them with selfs.
            pkwargs.update(kwargs)
            # replace
            kwargs = pkwargs

        # Make a new style
        style = reportlab.lib.styles.ListStyle(name=None)
        for name, value in kwargs.items():
            setattr(style, name, value)

        manager = attr.getManager(self)
        manager.styles[style.name] = style
        registerListStyle(manager.document, kwargs.get('name'), style, kwargs)


class Stylesheet(directive.RMLDirective):
    signature = rml_stylesheet.IStylesheet

    factories = {
        'initialize': Initialize,
        'paraStyle': ParagraphStyle,
        'spanStyle': SpanStyle,
        'blockTableStyle': BlockTableStyle,
        'listStyle': ListStyle,
    }