diff --git a/pydocx/export/base.py b/pydocx/export/base.py index 67b002bc..460259f4 100644 --- a/pydocx/export/base.py +++ b/pydocx/export/base.py @@ -15,12 +15,14 @@ NumberingSpan, NumberingSpanBuilder, ) +from pydocx.export.border_and_shading import BorderAndShadingBuilder from pydocx.openxml import markup_compatibility, vml, wordprocessing from pydocx.openxml.packaging import WordprocessingDocument class PyDocXExporter(object): numbering_span_builder_class = NumberingSpanBuilder + border_and_shading_builder_class = BorderAndShadingBuilder def __init__(self, path): self.path = path @@ -32,6 +34,9 @@ def __init__(self, path): self.captured_runs = None self.complex_field_runs = [] + self.paragraphs = [] + self.border_and_shading_builder = self.border_and_shading_builder_class( + self.paragraphs) self.node_type_to_export_func_map = { wordprocessing.Document: self.export_document, @@ -299,6 +304,9 @@ def yield_body_children(self, body): return self.yield_numbering_spans(body.children) def export_paragraph(self, paragraph): + if self.first_pass: + self.paragraphs.append(paragraph) + children = self.yield_paragraph_children(paragraph) results = self.yield_nested(children, self.export_node) if paragraph.effective_properties: diff --git a/pydocx/export/border_and_shading.py b/pydocx/export/border_and_shading.py new file mode 100644 index 00000000..c2f15b75 --- /dev/null +++ b/pydocx/export/border_and_shading.py @@ -0,0 +1,284 @@ +# coding: utf-8 +from __future__ import ( + absolute_import, + division, + print_function, + unicode_literals, +) + +from pydocx.export.html_tag import HtmlTag +from pydocx.export.numbering_span import NumberingItem +from pydocx.openxml.wordprocessing import Run, Paragraph, TableCell +from pydocx.util.xml import convert_dictionary_to_style_fragment + + +class NoBorderAndShadingBuilder(object): + TAGS = { + 'Paragraph': 'div', + 'Run': 'span' + } + + def __init__(self, items): + self.items = items + self.current_border_item = {} + self.item = None + self.prev_item = None + + def export_borders(self, item, results, first_pass=True): + if results: + for result in results: + yield result + + +class BorderAndShadingBuilder(NoBorderAndShadingBuilder): + @property + def item_is_paragraph(self): + return isinstance(self.item, Paragraph) + + @property + def item_is_run(self): + return isinstance(self.item, Run) + + @property + def item_name(self): + return self.item.__class__.__name__ + + @property + def tag_name(self): + return self.TAGS[self.item_name] + + def current_item_is_last_child(self, children, child_type): + for p_child in reversed(children): + if isinstance(p_child, child_type): + return p_child == self.item + return False + + def is_last_item(self): + if self.item_is_paragraph: + if isinstance(self.item.parent, (TableCell, NumberingItem)): + return self.current_item_is_last_child( + self.item.parent.children, Paragraph) + elif self.item == self.items[-1]: + return True + elif self.item_is_run: + # Check if current item is the last Run item from paragraph children + return self.current_item_is_last_child(self.item.parent.children, Run) + + return False + + def close_tag(self): + self.current_border_item[self.item_name] = None + return HtmlTag(self.tag_name, closed=True) + + def get_next_item(self): + next_item = None + + try: + cur_item_idx = self.items.index(self.item) + if cur_item_idx < len(self.items) - 1: + next_item = self.items[cur_item_idx + 1] + except ValueError: + pass + + return next_item + + def is_next_paragraph_listing(self): + """Check if current item is not listing but next one is listing""" + + if not self.item_is_paragraph: + return False + + next_item = self.get_next_item() + if next_item: + if not self.item.has_numbering_properties and next_item.has_numbering_properties: + return True + + return False + + def export_borders(self, item, results, first_pass=True): + if first_pass: + for result in results: + yield result + return + + self.item = item + + prev_borders_properties = None + prev_shading_properties = None + + border_properties = None + shading_properties = None + + current_border_item = self.current_border_item.get(self.item_name) + if current_border_item: + item_properties = current_border_item.effective_properties + prev_borders_properties = item_properties.border_properties + prev_shading_properties = item_properties.shading_properties + + last_item = False + close_border = True + + def prev_properties(): + return prev_borders_properties or prev_shading_properties + + def current_properties(): + return border_properties or shading_properties + + def properties_are_different(): + if border_properties != prev_borders_properties: + return True + elif shading_properties != prev_shading_properties: + return True + + return False + + def pre_close(): + """Check if we should close the tag before yielding other tags""" + self.item = item + + if not close_border: + return + elif prev_properties() is None: + return + + # At this stage we need to make sure that if there is an previously open tag + # about border/shading we need to close it + yield self.close_tag() + + def post_close(): + """Check if we should close the tag once all the inner tags were yielded""" + self.item = item + + if current_properties() and self.is_next_paragraph_listing(): + pass + elif not last_item: + return + elif current_properties() is None: + return + + # If the item with border/shading is the last one + # we need to make sure that we close the tag + yield self.close_tag() + + if item.effective_properties: + border_properties = item.effective_properties.border_properties + shading_properties = item.effective_properties.shading_properties + + if current_properties(): + last_item = self.is_last_item() + close_border = False + run_has_different_parent = False + + # If run is from different paragraph then we may need to draw separate border + # even if border properties are the same + if self.item_is_run and current_border_item: + if current_border_item.parent != item.parent: + run_has_different_parent = True + + if properties_are_different() or run_has_different_parent: + if prev_properties() is not None: + # We have a previous border/shading tag opened, so need to close it + yield HtmlTag(self.tag_name, closed=True) + + # Open a new tag for the new border/shading and include all the properties + attrs = self.get_borders_property() + yield HtmlTag(self.tag_name, closed=False, **attrs) + self.current_border_item[self.item_name] = item + + if border_properties == prev_borders_properties: + border_between = getattr(border_properties, 'between', None) + add_between_border = bool(border_between) + + if border_between and prev_borders_properties is not None: + if shading_properties: + if shading_properties == prev_shading_properties: + add_between_border = True + else: + + add_between_border = prev_borders_properties.bottom != \ + border_between + + if add_between_border: + # Render border between items + border_attrs = self.get_borders_property(only_between=True) + yield HtmlTag(self.tag_name, **border_attrs) + yield HtmlTag(self.tag_name, closed=True) + + for close_tag in pre_close(): + yield close_tag + + # All the inner items inside border/shading tag are issued here + if results: + for result in results: + yield result + + for close_tag in post_close(): + yield close_tag + + self.prev_item = item + + def reset_top_border_if_the_same(self): + if not self.prev_item or not self.prev_item.effective_properties: + return False + elif not self.prev_item.effective_properties.border_properties: + return False + elif not isinstance(self.prev_item, Paragraph): + return False + elif not isinstance(self.item, Paragraph): + return False + elif not self.item.have_same_numbering_properties_as(self.prev_item): + return False + + curr_border_properties = self.item.effective_properties.border_properties + prev_border_properties = self.prev_item.effective_properties.border_properties + + cur_top = curr_border_properties.top + prev_bottom = prev_border_properties.bottom + + all_borders_defined = all([ + curr_border_properties.borders_have_same_properties(), + prev_border_properties.borders_have_same_properties() + ]) + + if all_borders_defined and cur_top == prev_bottom: + return True + + return False + + def get_borders_property(self, only_between=False): + attrs = {} + style = {} + + border_properties = self.item.effective_properties.border_properties + shading_properties = self.item.effective_properties.shading_properties + + if border_properties: + if only_between: + style.update(border_properties.get_between_border_style()) + else: + style.update(border_properties.get_padding_style()) + style.update(border_properties.get_shadow_style()) + border_style = border_properties.get_border_style() + + # We need to reset one border if adjacent identical borders are met + if self.reset_top_border_if_the_same(): + border_style['border-top'] = '0' + style.update(border_style) + + if shading_properties and shading_properties.background_color: + style['background-color'] = '#{0}'.format(shading_properties.background_color) + + if style: + attrs['style'] = convert_dictionary_to_style_fragment(style) + + return attrs + + def export_close_paragraph_border(self): + if self.current_border_item.get('Paragraph'): + yield HtmlTag(self.TAGS['Paragraph'], closed=True) + self.current_border_item['Paragraph'] = None + + def export_close_run_border(self): + if self.current_border_item.get('Run'): + yield HtmlTag(self.TAGS['Run'], closed=True) + self.current_border_item['Run'] = None diff --git a/pydocx/export/html.py b/pydocx/export/html.py index 18e3ea53..90815c14 100644 --- a/pydocx/export/html.py +++ b/pydocx/export/html.py @@ -23,9 +23,11 @@ from pydocx.export.numbering_span import NumberingItem from pydocx.openxml import wordprocessing from pydocx.util.uri import uri_is_external -from pydocx.util.xml import ( - convert_dictionary_to_html_attributes, - convert_dictionary_to_style_fragment, +from pydocx.util.xml import convert_dictionary_to_style_fragment +from pydocx.export.html_tag import ( + HtmlTag, + is_only_whitespace, + is_not_empty_and_not_only_whitespace ) @@ -54,106 +56,6 @@ def get_first_from_sequence(sequence, default=None): return first_result -def is_only_whitespace(obj): - ''' - If the obj has `strip` return True if calling strip on the obj results in - an empty instance. Otherwise, return False. - ''' - if hasattr(obj, 'strip'): - return not obj.strip() - return False - - -def is_not_empty_and_not_only_whitespace(gen): - ''' - Determine if a generator is empty, or consists only of whitespace. - - If the generator is non-empty, return the original generator. Otherwise, - return None - ''' - queue = [] - if gen is None: - return - try: - for item in gen: - queue.append(item) - is_whitespace = True - if isinstance(item, HtmlTag): - # If we encounter a tag that allows whitespace, then we can stop - is_whitespace = not item.allow_whitespace - else: - is_whitespace = is_only_whitespace(item) - - if not is_whitespace: - # This item isn't whitespace, so we're done scanning - return chain(queue, gen) - - except StopIteration: - pass - - -class HtmlTag(object): - closed_tag_format = '' - - def __init__( - self, - tag, - allow_self_closing=False, - closed=False, - allow_whitespace=False, - **attrs - ): - self.tag = tag - self.allow_self_closing = allow_self_closing - self.attrs = attrs - self.closed = closed - self.allow_whitespace = allow_whitespace - - def apply(self, results, allow_empty=True): - if not allow_empty: - results = is_not_empty_and_not_only_whitespace(results) - if results is None: - return - - sequence = [[self]] - if results is not None: - sequence.append(results) - - if not self.allow_self_closing: - sequence.append([self.close()]) - - results = chain(*sequence) - - for result in results: - yield result - - def close(self): - return HtmlTag( - tag=self.tag, - closed=True, - ) - - def to_html(self): - if self.closed is True: - return self.closed_tag_format.format(tag=self.tag) - else: - attrs = self.get_html_attrs() - end_bracket = '>' - if self.allow_self_closing: - end_bracket = ' />' - if attrs: - return '<{tag} {attrs}{end}'.format( - tag=self.tag, - attrs=attrs, - end=end_bracket, - ) - else: - return '<{tag}{end}'.format(tag=self.tag, end=end_bracket) - - def get_html_attrs(self): - return convert_dictionary_to_html_attributes(self.attrs) - - class PyDocXHTMLExporter(PyDocXExporter): def __init__(self, *args, **kwargs): super(PyDocXHTMLExporter, self).__init__(*args, **kwargs) @@ -274,18 +176,28 @@ def get_heading_tag(self, paragraph): return HtmlTag(tag, id=paragraph.bookmark_name) return HtmlTag(tag) + def export_run(self, run): + results = super(PyDocXHTMLExporter, self).export_run(run) + + for result in self.border_and_shading_builder.export_borders( + run, results, first_pass=self.first_pass): + yield result + def export_paragraph(self, paragraph): results = super(PyDocXHTMLExporter, self).export_paragraph(paragraph) - results = is_not_empty_and_not_only_whitespace(results) - if results is None: + + # TODO@botzill In PR#234 we render empty paragraphs properly so + # we don't need this check anymore. Adding for now and to be removed when merging + if results is None and not paragraph.has_border_properties: return tag = self.get_paragraph_tag(paragraph) if tag: results = tag.apply(results) - for result in results: + for result in self.border_and_shading_builder.export_borders( + paragraph, results, first_pass=self.first_pass): yield result def export_paragraph_property_justification(self, paragraph, results): @@ -606,6 +518,12 @@ def export_hyperlink(self, hyperlink): # the export underline function. There's got to be a better way. old = self.export_run_property_underline self.export_run_property_underline = lambda run, results: results + + # Before starting new hyperlink we need to make sure that if there is any run + # with border opened before, we need to close it here. + for result in self.border_and_shading_builder.export_close_run_border(): + yield result + for result in results: yield result self.export_run_property_underline = old @@ -633,8 +551,17 @@ def export_table(self, table): table_cell_spans = table.calculate_table_cell_spans() self.table_cell_rowspan_tracking[table] = table_cell_spans results = super(PyDocXHTMLExporter, self).export_table(table) + + # Before starting new table new need to make sure that if there is any paragraph + # with border opened before, we need to close it here. + for result in self.border_and_shading_builder.export_close_paragraph_border(): + yield result + tag = self.get_table_tag(table) - return tag.apply(results) + results = tag.apply(results) + + for result in results: + yield result def export_table_row(self, table_row): results = super(PyDocXHTMLExporter, self).export_table_row(table_row) diff --git a/pydocx/export/html_tag.py b/pydocx/export/html_tag.py new file mode 100644 index 00000000..b1bb1c06 --- /dev/null +++ b/pydocx/export/html_tag.py @@ -0,0 +1,111 @@ +# coding: utf-8 +from __future__ import ( + absolute_import, + division, + print_function, + unicode_literals, +) + +from itertools import chain + +from pydocx.util.xml import convert_dictionary_to_html_attributes + + +def is_only_whitespace(obj): + ''' + If the obj has `strip` return True if calling strip on the obj results in + an empty instance. Otherwise, return False. + ''' + if hasattr(obj, 'strip'): + return not obj.strip() + return False + + +def is_not_empty_and_not_only_whitespace(gen): + ''' + Determine if a generator is empty, or consists only of whitespace. + + If the generator is non-empty, return the original generator. Otherwise, + return None + ''' + queue = [] + if gen is None: + return + try: + for item in gen: + queue.append(item) + is_whitespace = True + if isinstance(item, HtmlTag): + # If we encounter a tag that allows whitespace, then we can stop + is_whitespace = not item.allow_whitespace + else: + is_whitespace = is_only_whitespace(item) + + if not is_whitespace: + # This item isn't whitespace, so we're done scanning + return chain(queue, gen) + + except StopIteration: + pass + + +class HtmlTag(object): + closed_tag_format = '' + + def __init__( + self, + tag, + allow_self_closing=False, + closed=False, + allow_whitespace=False, + **attrs + ): + self.tag = tag + self.allow_self_closing = allow_self_closing + self.attrs = attrs + self.closed = closed + self.allow_whitespace = allow_whitespace + + def apply(self, results, allow_empty=True): + if not allow_empty: + results = is_not_empty_and_not_only_whitespace(results) + if results is None: + return + + sequence = [[self]] + if results is not None: + sequence.append(results) + + if not self.allow_self_closing: + sequence.append([self.close()]) + + results = chain(*sequence) + + for result in results: + yield result + + def close(self): + return HtmlTag( + tag=self.tag, + closed=True, + ) + + def to_html(self): + if self.closed is True: + return self.closed_tag_format.format(tag=self.tag) + else: + attrs = self.get_html_attrs() + end_bracket = '>' + if self.allow_self_closing: + end_bracket = ' />' + if attrs: + return '<{tag} {attrs}{end}'.format( + tag=self.tag, + attrs=attrs, + end=end_bracket, + ) + else: + return '<{tag}{end}'.format(tag=self.tag, end=end_bracket) + + def get_html_attrs(self): + return convert_dictionary_to_html_attributes(self.attrs) diff --git a/pydocx/export/numbering_span.py b/pydocx/export/numbering_span.py index 809ff672..a48a65dd 100644 --- a/pydocx/export/numbering_span.py +++ b/pydocx/export/numbering_span.py @@ -295,10 +295,10 @@ def detect_parent_child_map_for_items(self): list_start_stop_index = {} # we are interested only in components that are part of the listing components = [component for component in self.components if - hasattr(component, 'properties') - and hasattr(component.properties, 'numbering_properties') - and component.numbering_definition - and component.get_numbering_level()] + hasattr(component, 'properties') and + hasattr(component.properties, 'numbering_properties') and + component.numbering_definition and + component.get_numbering_level()] if not components: return False diff --git a/pydocx/models.py b/pydocx/models.py index a8b2e2b6..806e370a 100644 --- a/pydocx/models.py +++ b/pydocx/models.py @@ -208,7 +208,7 @@ def __init__( parent=None, **kwargs ): - for field_name, field in self.__class__.__dict__.items(): + for field_name, field in self._get_all_attributes(self.__class__).items(): if isinstance(field, XmlField): # TODO field.default may only refer to the attr, and not if the # field itself is missing @@ -224,6 +224,24 @@ def __init__( self._parent = parent self.container = kwargs.get('container') + @classmethod + def _get_all_attributes(cls, klass): + """Return a set of the accessible attributes of class/type klass. + + This includes all attributes of klass and all of the base classes + recursively. + """ + + attrs = {} + ns = getattr(klass, '__dict__', None) + if ns is not None: + attrs.update(ns) + bases = getattr(klass, '__bases__', []) + for base in bases: + attrs.update(cls._get_all_attributes(base)) + + return attrs + @property def parent(self): return self._parent @@ -263,7 +281,7 @@ def fields(self): model, and yields back only those fields which have been set to a value that isn't the field's default. ''' - for field_name, field in self.__class__.__dict__.items(): + for field_name, field in self._get_all_attributes(self.__class__).items(): if isinstance(field, XmlField): value = getattr(self, field_name, field.default) if value != field.default: @@ -288,7 +306,7 @@ def load(cls, element, **load_kwargs): # Enumerate the defined fields and separate them into attributes and # tags - for field_name, field in cls.__dict__.items(): + for field_name, field in cls._get_all_attributes(cls).items(): if isinstance(field, XmlAttribute): attribute_fields[field_name] = field if isinstance(field, XmlChild): diff --git a/pydocx/openxml/wordprocessing/__init__.py b/pydocx/openxml/wordprocessing/__init__.py index 4fce72a2..45f8dca6 100644 --- a/pydocx/openxml/wordprocessing/__init__.py +++ b/pydocx/openxml/wordprocessing/__init__.py @@ -24,6 +24,8 @@ from pydocx.openxml.wordprocessing.numbering_properties import NumberingProperties # noqa from pydocx.openxml.wordprocessing.paragraph import Paragraph from pydocx.openxml.wordprocessing.paragraph_properties import ParagraphProperties # noqa +from pydocx.openxml.wordprocessing.border_properties import ParagraphBorders +from pydocx.openxml.wordprocessing.border_properties import RunBorders from pydocx.openxml.wordprocessing.picture import Picture from pydocx.openxml.wordprocessing.run import Run from pydocx.openxml.wordprocessing.run_properties import RunProperties # noqa @@ -71,9 +73,11 @@ 'NumberingProperties', 'Paragraph', 'ParagraphProperties', + 'ParagraphBorders', 'Picture', 'Run', 'RunProperties', + 'RunBorders', 'RFonts', 'SdtBlock', 'SdtContentBlock', diff --git a/pydocx/openxml/wordprocessing/border_properties.py b/pydocx/openxml/wordprocessing/border_properties.py new file mode 100644 index 00000000..27c2b7a9 --- /dev/null +++ b/pydocx/openxml/wordprocessing/border_properties.py @@ -0,0 +1,253 @@ +from __future__ import absolute_import, print_function, unicode_literals + +from pydocx.models import XmlModel, XmlChild, XmlAttribute +from pydocx.types import OnOff + +BORDERS_CSS_DEFAULT = 'solid' +BORDERS_CSS_MAPPING = { + 'none': 'none', + 'single': 'solid', + 'double': 'double', + 'triple': 'double', + 'dotted': 'dotted', + 'dashed': 'dashed', + 'dashSmallGap': 'dashed', + 'dotDash': 'dashed', + 'dotDotDash': 'dashed', + 'outset': 'outset' +} + + +class BaseBorder(XmlModel): + style = XmlAttribute(name='val') + width = XmlAttribute(name='sz') + _color = XmlAttribute(name='color') + space = XmlAttribute(name='space') + shadow = XmlAttribute(type=OnOff, name='shadow') + + @property + def color(self): + if self._color in ('auto',): + self._color = '000000' + return self._color + + @property + def css_style(self): + if self.style: + return BORDERS_CSS_MAPPING.get(self.style, BORDERS_CSS_DEFAULT) + return BORDERS_CSS_DEFAULT + + @property + def width_points(self): + """width is of type ST_EighthPointMeasure""" + + if self.width: + width = int(self.width) / 8.0 + if self.style in ('double',): + width *= 3 + elif self.style in ('triple',): + width *= 5 + width = float('%.1f' % width) + else: + width = 1 + + return width + + @property + def spacing(self): + if self.space: + return self.space + # If space is not defined we assume 0 by default + return '0' + + def get_css_border_style(self, to_str=True): + border_style = [ + # border-width + '%spt' % self.width_points, + # border-style + self.css_style, + # border-color + '#%s' % self.color + ] + if to_str: + border_style = ' '.join(border_style) + return border_style + + @classmethod + def attributes_list(cls, obj): + if obj: + return (obj.css_style, + obj.width, + obj.color, + obj.spacing, + obj.shadow) + + def __eq__(self, other): + return self.attributes_list(self) == self.attributes_list(other) + + def __ne__(self, other): + return not self == other + + def __bool__(self): + if self.style and self.style in ['nil', 'none']: + return False + return True + + +class TopBorder(BaseBorder): + XML_TAG = 'top' + + +class LeftBorder(BaseBorder): + XML_TAG = 'left' + + +class BottomBorder(BaseBorder): + XML_TAG = 'bottom' + + +class RightBorder(BaseBorder): + XML_TAG = 'right' + + +class BetweenBorder(BaseBorder): + XML_TAG = 'between' + + +class BaseBorderStyle(object): + def get_border_style(self): + raise NotImplemented + + def get_shadow_style(self): + raise NotImplemented + + def get_padding_style(self): + raise NotImplemented + + +class ParagraphBorders(XmlModel, BaseBorderStyle): + XML_TAG = 'pBdr' + top = XmlChild(type=TopBorder) + left = XmlChild(type=LeftBorder) + bottom = XmlChild(type=BottomBorder) + right = XmlChild(type=RightBorder) + between = XmlChild(type=BetweenBorder) + + @property + def borders_name(self): + """Borders are listed by how CSS is expecting them to be""" + + return 'top', 'right', 'bottom', 'left' + + @property + def all_borders(self): + return list(map(lambda brd_name: getattr(self, brd_name), self.borders_name)) + + def get_borders_attribute(self, attr_name, default=None, to_type=None): + attributes = list(map(lambda brd: getattr(brd, attr_name, default), self.all_borders)) + if to_type is not None: + if to_type is set: + attributes = set(attributes) + else: + attributes = list(map(to_type, attributes)) + + return attributes + + def borders_have_same_properties(self): + if not any(self.all_borders): + return False + + color = self.get_borders_attribute('color', to_type=set) + style = self.get_borders_attribute('style', to_type=set) + width = self.get_borders_attribute('width', to_type=set) + + if list(set(map(len, (color, style, width)))) == [1]: + return True + + return False + + def get_border_style(self): + border_styles = {} + if self.borders_have_same_properties(): + border_styles['border'] = self.top.get_css_border_style() + else: + for border_name, border in zip(self.borders_name, self.all_borders): + if border: + border_styles['border-%s' % border_name] = border.get_css_border_style() + + return border_styles + + def get_between_border_style(self): + border_styles = {} + if self.between: + border_styles['border-top'] = self.between.get_css_border_style() + + # Because there can be padding added by the parent border we need to make sure that + # we adapt margins to not have extra space on left/right + margins = [ + # top + self.between.spacing, + # right + '%s' % -int(getattr(self.right, 'spacing', 0)), + # bottom + self.between.spacing, + # left + '%s' % -int(getattr(self.left, 'spacing', 0)) + ] + + border_styles['margin'] = ' '.join(map(lambda x: '%spt' % x, margins)) + return border_styles + + def get_padding_style(self): + padding_styles = {} + padding = self.get_borders_attribute('spacing', default=0, to_type=str) + if len(set(padding)) == 1: + padding = list(set(padding)) + padding_styles['padding'] = ' '.join(map(lambda x: '%spt' % x, padding)) + return padding_styles + + def get_shadow_style(self): + shadow_styles = {} + border = self.top + if border and bool(border.shadow): + shadow_styles['box-shadow'] = '{0}pt {0}pt'.format(border.width_points) + return shadow_styles + + @classmethod + def attributes_list(cls, obj): + if obj is not None: + return (obj.top, + obj.left, + obj.bottom, + obj.right, + obj.between) + + def __eq__(self, other): + return self.attributes_list(self) == self.attributes_list(other) + + def __ne__(self, other): + return not self == other + + def __bool__(self): + return any(self.attributes_list(self) or [None]) + + +class RunBorders(BaseBorder, BaseBorderStyle): + XML_TAG = 'bdr' + + def get_border_style(self): + border_styles = {'border': self.get_css_border_style()} + return border_styles + + def get_shadow_style(self): + shadow_styles = {} + if bool(self.shadow): + shadow_styles['box-shadow'] = '{0}pt {0}pt'.format(self.width_points) + return shadow_styles + + def get_padding_style(self): + padding_styles = {} + # if spacing is 0 no need to set the padding + if int(self.spacing): + padding_styles['padding'] = '%spt' % self.spacing + return padding_styles diff --git a/pydocx/openxml/wordprocessing/numbering_properties.py b/pydocx/openxml/wordprocessing/numbering_properties.py index ff1ebb7f..ab66e5bc 100644 --- a/pydocx/openxml/wordprocessing/numbering_properties.py +++ b/pydocx/openxml/wordprocessing/numbering_properties.py @@ -21,3 +21,14 @@ def is_root_level(self): return False return self.level_id == self.ROOT_LEVEL_ID + + @classmethod + def attributes_list(cls, obj): + if obj: + return obj.level_id, obj.num_id + + def __eq__(self, other): + return self.attributes_list(self) == self.attributes_list(other) + + def __ne__(self, other): + return not self == other diff --git a/pydocx/openxml/wordprocessing/paragraph.py b/pydocx/openxml/wordprocessing/paragraph.py index bdb6b387..d2a5a05c 100644 --- a/pydocx/openxml/wordprocessing/paragraph.py +++ b/pydocx/openxml/wordprocessing/paragraph.py @@ -183,13 +183,18 @@ def get_number_of_initial_tabs(self): @property @memoized def has_numbering_properties(self): - return bool(getattr(self.properties, 'numbering_properties', None)) + return bool(getattr(self.effective_properties, 'numbering_properties', None)) @property @memoized def has_numbering_definition(self): return bool(self.numbering_definition) + @property + @memoized + def has_border_properties(self): + return bool(getattr(self.effective_properties, 'border_properties', None)) + def get_indentation(self, indentation, only_level_ind=False): ''' Get specific indentation of the current paragraph. If indentation is @@ -206,3 +211,12 @@ def get_indentation(self, indentation, only_level_ind=False): ind = level.paragraph_properties.to_int(indentation, default=0) return ind + + def have_same_numbering_properties_as(self, paragraph): + prop1 = getattr(self.effective_properties, 'numbering_properties', None) + prop2 = getattr(paragraph.effective_properties, 'numbering_properties', None) + + if prop1 == prop2: + return True + + return False diff --git a/pydocx/openxml/wordprocessing/paragraph_properties.py b/pydocx/openxml/wordprocessing/paragraph_properties.py index c6bbc374..6903a3db 100644 --- a/pydocx/openxml/wordprocessing/paragraph_properties.py +++ b/pydocx/openxml/wordprocessing/paragraph_properties.py @@ -7,6 +7,8 @@ from pydocx.models import XmlModel, XmlChild from pydocx.openxml.wordprocessing.numbering_properties import NumberingProperties # noqa +from pydocx.openxml.wordprocessing.border_properties import ParagraphBorders +from pydocx.openxml.wordprocessing.shading_properties import ParagraphShading class ParagraphProperties(XmlModel): @@ -14,6 +16,8 @@ class ParagraphProperties(XmlModel): parent_style = XmlChild(name='pStyle', attrname='val') numbering_properties = XmlChild(type=NumberingProperties) + border_properties = XmlChild(type=ParagraphBorders) + shading_properties = XmlChild(type=ParagraphShading) justification = XmlChild(name='jc', attrname='val') # TODO ind can appear multiple times. Need to merge them in document order # This probably means other elements can appear multiple times diff --git a/pydocx/openxml/wordprocessing/run_properties.py b/pydocx/openxml/wordprocessing/run_properties.py index 63587a57..81bba0e2 100644 --- a/pydocx/openxml/wordprocessing/run_properties.py +++ b/pydocx/openxml/wordprocessing/run_properties.py @@ -8,6 +8,8 @@ from pydocx.models import XmlModel, XmlChild from pydocx.types import OnOff, Underline from pydocx.openxml.wordprocessing.rfonts import RFonts +from pydocx.openxml.wordprocessing.border_properties import RunBorders +from pydocx.openxml.wordprocessing.shading_properties import RunShading class RunProperties(XmlModel): @@ -28,6 +30,8 @@ class RunProperties(XmlModel): sz = XmlChild(name='sz', attrname='val') clr = XmlChild(name='color', attrname='val') r_fonts = XmlChild(type=RFonts) + border_properties = XmlChild(type=RunBorders) + shading_properties = XmlChild(type=RunShading) @property def color(self): diff --git a/pydocx/openxml/wordprocessing/shading_properties.py b/pydocx/openxml/wordprocessing/shading_properties.py new file mode 100644 index 00000000..0dd328d9 --- /dev/null +++ b/pydocx/openxml/wordprocessing/shading_properties.py @@ -0,0 +1,53 @@ +from __future__ import absolute_import, print_function, unicode_literals + +from pydocx.models import XmlModel, XmlAttribute + + +class BaseShading(XmlModel): + pattern = XmlAttribute(name='val') + color = XmlAttribute(name='color') + fill = XmlAttribute(name='fill') + + @property + def background_color(self): + """Get the background color for shading. Note that this is a simple + implementation as we can't translate all the shadings into CSS""" + + color = None + + if self.pattern in ('solid',) and self.color: + if self.color in ('auto',): + # By default we set the color to black if auto is specified + color = '000000' + else: + color = self.color + elif self.fill and self.fill not in ('auto',): + color = self.fill + elif self.color not in ('auto',): + color = self.color + + return color + + @classmethod + def attributes_list(cls, obj): + if obj is not None: + return (obj.pattern, + obj.color, + obj.fill) + + def __eq__(self, other): + return self.attributes_list(self) == self.attributes_list(other) + + def __ne__(self, other): + return not self == other + + def __nonzero__(self): + return any(self.attributes_list(self) or [None]) + + +class ParagraphShading(BaseShading): + XML_TAG = 'shd' + + +class RunShading(BaseShading): + XML_TAG = 'shd' diff --git a/pydocx/test/document_builder.py b/pydocx/test/document_builder.py index 589fec16..5e25dd9d 100644 --- a/pydocx/test/document_builder.py +++ b/pydocx/test/document_builder.py @@ -62,12 +62,42 @@ def xml(self, body): body=body.decode('utf-8'), ) + @classmethod + def xml_borders(cls, border_dict): + borders = [] + if border_dict: + # Create xml for border properties + border_xml = '' + for border_name, border_attributes in border_dict.items(): + border_attrs = ' '.join(('w:%s="%s"' % + (key, val) for key, val in border_attributes.items())) + borders.append( + border_xml.format(border_name=border_name, border_attrs=border_attrs)) + + borders = '\n'.join(borders) + + return borders or None + + @classmethod + def xml_shading(cls, shading_dict): + shading = '' + if shading_dict is not None: + shading = '' + shading_attrs = ' '.join(('w:%s="%s"' % + (key, val) for key, val in shading_dict.items())) + + shading = shading.format(shading_attrs=shading_attrs) + + return shading + @classmethod def p_tag( self, text, style='style0', jc=None, + borders=None, + shading=None ): if isinstance(text, string_types): # Use create a single r tag based on the text and the bold @@ -86,6 +116,8 @@ def p_tag( run_tags=run_tags, style=style, jc=jc, + borders=self.xml_borders(borders), + shading=self.xml_shading(shading) ) @classmethod @@ -103,18 +135,23 @@ def r_tag( self, elements, rpr=None, + borders=None, + shading=None ): template = env.get_template(templates['r']) if rpr is None: - rpr = DocxBuilder.rpr_tag() + rpr = DocxBuilder.rpr_tag( + borders=self.xml_borders(borders), + shading=self.xml_shading(shading), + ) return template_render( template, elements=elements, - rpr=rpr, + rpr=rpr ) @classmethod - def rpr_tag(self, inline_styles=None, *args, **kwargs): + def rpr_tag(self, inline_styles=None, borders=None, shading=None, *args, **kwargs): if inline_styles is None: inline_styles = {} valid_styles = ( @@ -136,6 +173,8 @@ def rpr_tag(self, inline_styles=None, *args, **kwargs): return template_render( template, tags=inline_styles, + borders=borders, + shading=shading ) @classmethod diff --git a/tests/export/test_docx.py b/tests/export/test_docx.py index 16fa43d1..6735a672 100644 --- a/tests/export/test_docx.py +++ b/tests/export/test_docx.py @@ -57,6 +57,8 @@ class ConvertDocxToHtmlTestCase(DocXFixtureTestCaseFactory): 'tables_in_lists', 'textbox', 'track_changes_on', + 'paragraphs_with_borders', + 'paragraphs_with_borders_and_shading', ) @raises(MalformedDocxException) diff --git a/tests/export/test_xml.py b/tests/export/test_xml.py index 2ad17a88..ed89ed7d 100644 --- a/tests/export/test_xml.py +++ b/tests/export/test_xml.py @@ -1168,3 +1168,453 @@ def get_xml(self): xml = DXB.xml(lis) return xml + + +class ParagraphBordersTestCase(TranslationTestCase): + expected_output = ''' +
+

AAA

+
+
+

BBB

+
+ ''' + + def get_xml(self): + p1_border = { + 'top': { + 'color': 'auto' + } + } + p2_border = { + 'bottom': { + 'val': 'single', + 'sz': '5', + 'space': '2', + 'color': '0000FF' + } + } + p_tags = [ + DXB.p_tag('AAA', borders=p1_border), + DXB.p_tag('BBB', borders=p2_border) + ] + body = b'' + for p_tag in p_tags: + body += p_tag + xml = DXB.xml(body) + return xml + + +class ParagraphBordersSameTopBottomTestCase(TranslationTestCase): + expected_output = ''' +
+

AAA

+
+
+

BBB

+
+ ''' + + def get_xml(self): + p1_border = { + 'top': { + 'val': 'single', + 'sz': '5', + 'space': '2', + 'color': '0000FF' + }, + 'bottom': { + 'val': 'single', + 'sz': '5', + 'space': '2', + 'color': '0000FF' + }, + 'left': { + 'val': 'single', + 'sz': '5', + 'space': '2', + 'color': '0000FF' + }, + 'right': { + 'val': 'single', + 'sz': '5', + 'space': '2', + 'color': '0000FF' + }, + } + p2_border = { + 'top': { + 'val': 'single', + 'sz': '5', + 'space': '2', + 'color': '0000FF' + } + } + + p_tags = [ + DXB.p_tag('AAA', borders=p1_border), + DXB.p_tag('BBB', borders=p2_border), + ] + body = b'' + for p_tag in p_tags: + body += p_tag + xml = DXB.xml(body) + return xml + + +class ParagraphBordersSameTopBottomWithShadingTestCase(TranslationTestCase): + expected_output = ''' +
+

AAA

+
+
+

BBB

+
+''' + + def get_xml(self): + p1_border = { + 'top': { + 'val': 'single', + 'sz': '5', + 'space': '2', + 'color': '0000FF' + }, + 'bottom': { + 'val': 'single', + 'sz': '5', + 'space': '2', + 'color': '0000FF' + }, + 'left': { + 'val': 'single', + 'sz': '5', + 'space': '2', + 'color': '0000FF' + }, + 'right': { + 'val': 'single', + 'sz': '5', + 'space': '2', + 'color': '0000FF' + }, + } + p1_shading = { + 'fill': '0000FF', + } + p2_border = p1_border.copy() + p2_shading = { + 'fill': 'FF00FF', + } + + p_tags = [ + DXB.p_tag('AAA', borders=p1_border, shading=p1_shading), + DXB.p_tag('BBB', borders=p2_border, shading=p2_shading), + ] + body = b'' + for p_tag in p_tags: + body += p_tag + xml = DXB.xml(body) + return xml + + +class ParagraphShadingTestCase(TranslationTestCase): + expected_output = ''' +
+

AAA

+

BBB

+
+
+

CCC

+
+
+

DDD

+
+ ''' + + def get_xml(self): + # + p1_shading = { + 'fill': '0000FF', + } + p2_shading = { + 'fill': '0000FF', + } + p3_shading = { + 'val': 'solid', + 'color': 'auto' + } + p4_shading = { + 'val': 'solid', + 'color': 'FF00FF' + } + + p_tags = [ + DXB.p_tag('AAA', shading=p1_shading), + DXB.p_tag('BBB', shading=p2_shading), + DXB.p_tag('CCC', shading=p3_shading), + DXB.p_tag('DDD', shading=p4_shading), + ] + body = b'' + for p_tag in p_tags: + body += p_tag + xml = DXB.xml(body) + return xml + + +class ParagraphBorderAndShadingTestCase(TranslationTestCase): + expected_output = ''' +
+

AAA

+

BBB

+
+
+

CCC

+
+
+

DDD

+
+ ''' + + def get_xml(self): + p1_border = { + 'top': { + 'val': 'single', + 'sz': '5', + 'space': '0', + 'color': '0000FF' + } + } + p1_shading = { + 'fill': '0000FF', + } + p2_border = { + 'top': { + 'val': 'single', + 'sz': '5', + 'space': '0', + 'color': '0000FF' + } + } + p2_shading = { + 'fill': '0000FF', + } + p3_border = { + 'bottom': { + 'val': 'single', + 'sz': '5', + 'space': '0', + 'color': '0000FF' + } + } + p3_shading = { + 'val': 'solid', + 'color': 'FF00FF', + 'fill': '000000', + } + + p4_border = { + 'bottom': { + 'val': 'single', + 'sz': '5', + 'space': '0', + 'color': '0000FF' + } + } + p4_shading = { + 'val': 'solid', + 'color': 'FFFFFF', + 'fill': '000000', + } + + p_tags = [ + DXB.p_tag('AAA', borders=p1_border, shading=p1_shading), + DXB.p_tag('BBB', borders=p2_border, shading=p2_shading), + DXB.p_tag('CCC', borders=p3_border, shading=p3_shading), + DXB.p_tag('DDD', borders=p4_border, shading=p4_shading), + ] + body = b'' + for p_tag in p_tags: + body += p_tag + xml = DXB.xml(body) + return xml + + +class RunBordersTestCase(TranslationTestCase): + expected_output = ''' +

+ AAA + BBB +

+ ''' + + def get_xml(self): + r1_border = { + 'bdr': { + 'color': '00FF00', + 'space': '0' + } + } + r2_border = { + 'bdr': { + 'color': '00FF00', + 'space': '1' + } + } + + p_tags = [ + DXB.p_tag( + [ + DXB.r_tag( + [DXB.t_tag('AAA')], + borders=r1_border + ), + DXB.r_tag( + [DXB.t_tag('BBB')], + borders=r2_border + ), + ], + ) + ] + body = b'' + for p_tag in p_tags: + body += p_tag + + xml = DXB.xml(body) + return xml + + +class RunShadingTestCase(TranslationTestCase): + expected_output = ''' +

+ AAABBB + CCC + DDD +

+ ''' + + def get_xml(self): + r1_shading = { + 'fill': '0000FF', + } + r2_shading = { + 'fill': '0000FF', + } + r3_shading = { + 'val': 'solid', + 'color': 'auto' + } + r4_shading = { + 'val': 'solid', + 'color': 'FF00FF' + } + + p_tags = [ + DXB.p_tag( + [ + DXB.r_tag( + [DXB.t_tag('AAA')], + shading=r1_shading + ), + DXB.r_tag( + [DXB.t_tag('BBB')], + shading=r2_shading + ), + DXB.r_tag( + [DXB.t_tag('CCC')], + shading=r3_shading + ), + DXB.r_tag( + [DXB.t_tag('DDD')], + shading=r4_shading + ), + ], + ) + ] + body = b'' + for p_tag in p_tags: + body += p_tag + xml = DXB.xml(body) + return xml + + +class RunBordersAndShadingTestCase(TranslationTestCase): + expected_output = ''' +

+ AAABBB + CCC + DDD +

+''' + + def get_xml(self): + r1_border = { + 'bdr': { + 'color': '00FF00', + 'space': '0' + } + } + r1_shading = { + 'fill': '0000FF', + } + r2_border = { + 'bdr': { + 'color': '00FF00', + 'space': '0' + } + } + r2_shading = { + 'fill': '0000FF', + } + r3_border = { + 'bdr': { + 'color': 'FFFF00', + 'space': '1' + } + } + r3_shading = { + 'val': 'solid', + 'color': 'auto' + } + r4_border = { + 'bdr': { + 'color': 'FFFF00', + 'space': '1' + } + } + r4_shading = { + 'val': 'solid', + 'color': 'FF00FF' + } + + p_tags = [ + DXB.p_tag( + [ + DXB.r_tag( + [DXB.t_tag('AAA')], + borders=r1_border, + shading=r1_shading + ), + DXB.r_tag( + [DXB.t_tag('BBB')], + borders=r2_border, + shading=r2_shading + ), + DXB.r_tag( + [DXB.t_tag('CCC')], + borders=r3_border, + shading=r3_shading + ), + DXB.r_tag( + [DXB.t_tag('DDD')], + borders=r4_border, + shading=r4_shading + ), + ], + ) + ] + body = b'' + for p_tag in p_tags: + body += p_tag + xml = DXB.xml(body) + return xml diff --git a/tests/fixtures/paragraphs_with_borders.docx b/tests/fixtures/paragraphs_with_borders.docx new file mode 100644 index 00000000..686f5d35 Binary files /dev/null and b/tests/fixtures/paragraphs_with_borders.docx differ diff --git a/tests/fixtures/paragraphs_with_borders.html b/tests/fixtures/paragraphs_with_borders.html new file mode 100644 index 00000000..ff9539de --- /dev/null +++ b/tests/fixtures/paragraphs_with_borders.html @@ -0,0 +1,85 @@ +
+

AAA

+

BBB

+

+ CCC +

+
+
+

DDD

+

EEE

+
+
+

FFF

+
+

GGG

+
+
+

+ HHH III +

+

+ JJJ KKK +

+

+ LLL +

+

MMM

+
+

Simple Paragraph

+
+

NNN

+

OOO

+
+
+

PPP

+
+
+

QQQ

+

RRR

+
+
+

+
+ + + + + + + + + +
+
SSS
+
+
TTT
+
+
+ UUU +
+
+
VVV
+
+

White border

+
    +
  1. +
    XXX
    +
      +
    1. +
      XXX1
      +
    2. +
    3. +
      XXX2
      +
    4. +
    +
  2. +
  3. +
    + YYY +
    +
  4. +
  5. + ZZZ +
  6. +
diff --git a/tests/fixtures/paragraphs_with_borders_and_shading.docx b/tests/fixtures/paragraphs_with_borders_and_shading.docx new file mode 100644 index 00000000..8d7e382e Binary files /dev/null and b/tests/fixtures/paragraphs_with_borders_and_shading.docx differ diff --git a/tests/fixtures/paragraphs_with_borders_and_shading.html b/tests/fixtures/paragraphs_with_borders_and_shading.html new file mode 100644 index 00000000..47cba891 --- /dev/null +++ b/tests/fixtures/paragraphs_with_borders_and_shading.html @@ -0,0 +1,66 @@ +

AAA

+

BBB

+

CCC

+

DDD

+

EEE FFF GGG

+

+ HHH

+

III

+
+

JJJ

+

KKK

+

LLL

+

+ MMM

+
+

NNN

+
+

OOO

+
+

PPP

+
+

QQQ

+

RRR

+
+

SSS

+ + + + + + + + + +
+
TTT
UUU
+
+
VVV
WWW
+
XXX +
YYY ZZZ +
+
+
+

Alpha

+

Beta

+
+
    +
  1. +
    + Gamma +
    +
      +
    1. +
      + Delta +
      +
    2. +
    +
  2. +
  3. +
    + Epsilon +
    +
  4. +
\ No newline at end of file diff --git a/tests/templates/p.xml b/tests/templates/p.xml index 7a78a060..4c098e18 100644 --- a/tests/templates/p.xml +++ b/tests/templates/p.xml @@ -12,6 +12,14 @@ {% endif %} {% if jc %}{% endif %} + {% if borders %} + + {{ borders }} + + {% endif %} + {% if shading %} + {{ shading }} + {% endif %} {% for run_tag in run_tags %} {{ run_tag }} diff --git a/tests/templates/r.xml b/tests/templates/r.xml index 2f28a66b..e5cfcb42 100644 --- a/tests/templates/r.xml +++ b/tests/templates/r.xml @@ -3,4 +3,5 @@ {% for element in elements %} {{ element }} {% endfor %} + diff --git a/tests/templates/rpr.xml b/tests/templates/rpr.xml index f49eb08b..cd439b7b 100644 --- a/tests/templates/rpr.xml +++ b/tests/templates/rpr.xml @@ -2,4 +2,10 @@ {% for tag, value in tags.items() %} {% endfor %} + {% if borders %} + {{ borders }} + {% endif %} + {% if shading %} + {{ shading }} + {% endif %} diff --git a/tests/test_models.py b/tests/test_models.py index 591e055c..359613e4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -15,7 +15,6 @@ XmlModel, XmlRootElementMismatchException, ) - from pydocx.util.xml import parse_xml_from_string @@ -69,6 +68,17 @@ class BucketModel(XmlModel): circle_size = XmlChild(name='circle', attrname='sz') +class BaseItemModel(XmlModel): + val1 = XmlAttribute(name='val1') + val2 = XmlAttribute(name='val2') + + +class ItemModel(BaseItemModel): + XML_TAG = 'item' + + val3 = XmlAttribute(name='val3') + + class BaseTestCase(TestCase): def _get_model_instance_from_xml(self, xml): root = parse_xml_from_string(xml) @@ -298,3 +308,17 @@ def test_apples_and_oranges_models_accessed_through_collection(self): 'four', ] self.assertEqual(types, expected_types) + + +class InheritFromBaseModelTestCase(BaseTestCase): + model = ItemModel + + def test_attributes_available_from_base_class(self): + xml = ''' + + ''' + item = self._get_model_instance_from_xml(xml) + self.assertEqual( + {'val1': 'item_val1', 'val2': 'item_val2', 'val3': 'item_val3'}, + dict(item.fields) + )