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 = '{tag}>'
-
- 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 = '{tag}>'
+
+ 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 = '
AAA
+BBB
+AAA
+BBB
+AAA
+BBB
+AAA
+BBB
+CCC
+DDD
+AAA
+BBB
+CCC
+DDD
++ 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
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
+