diff --git a/.gitignore b/.gitignore index de25a6f76..2f010f907 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,10 @@ _scratch/ Session.vim /.tox/ +*.swp +*.cache +.Python +bin +include +lib +pip-selfcheck.json diff --git a/docs/dev/analysis/features/shapes/shapes-anchor.rst b/docs/dev/analysis/features/shapes/shapes-anchor.rst new file mode 100644 index 000000000..e08753482 --- /dev/null +++ b/docs/dev/analysis/features/shapes/shapes-anchor.rst @@ -0,0 +1,232 @@ +Anchor shape +============ + +Word allows a graphical object to be placed into a document as a floating +object. A floating shape appears as a ```` element as a child of +a ```` element and has a ```` child. + + +Candidate protocol -- anchor shape access +----------------------------------------- + +The following interactive session illustrates the protocol for accessing an +anchor shape:: + + >>> shapes = document.body.anchor_shapes + >>> shape = shapes[0] + >>> assert shape.type == MSO_SHAPE_TYPE.PICTURE + + +Resources +--------- + +* `Document Members (Word) on MSDN`_ +* `Shape Members (Word) on MSDN`_ + +.. _Document Members (Word) on MSDN: + http://msdn.microsoft.com/en-us/library/office/ff840898.aspx + +.. _Shape Members (Word) on MSDN: + http://msdn.microsoft.com/en-us/library/office/ff195191.aspx + + +MS API +------ + +The Shapes and InlineShapes properties on Document hold references to things +like pictures in the MS API. + +* Height and Width +* Borders +* Shadow +* Hyperlink +* PictureFormat (providing brightness, color, crop, transparency, contrast) +* ScaleHeight and ScaleWidth +* HasChart +* HasSmartArt +* Type (Chart, LockedCanvas, Picture, SmartArt, etc.) + + +Spec references +--------------- + +* 17.3.3.9 drawing (DrawingML Object) +* 20.4.2.3 anchor (Anchor DrawingML Object) +* 20.4.2.7 extent (Drawing Object Size) + + +Minimal XML +----------- + +.. highlight:: xml + +This XML represents my best guess of the minimal inline shape container that +Word will load:: + + + + + + + right + + + center + + + + + + + + + + + + + + + + +Specimen XML +------------ + +.. highlight:: xml + +A ``CT_Drawing`` (````) element can appear in a run, as a peer of, +for example, a ```` element. This element contains a DrawingML object. +WordprocessingML drawings are discussed in section 20.4 of the ISO/IEC spec. + +This XML represents an inline shape inserted inline on a paragraph by itself. +The particulars of the graphical object itself are redacted:: + + + + + + + + + + + + + + + + + + + + + + + + + + + +Schema definitions +------------------ + +.. highlight:: xml + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docx/document.py b/docx/document.py index ba94a7990..51f4a87bb 100644 --- a/docx/document.py +++ b/docx/document.py @@ -62,7 +62,10 @@ def add_paragraph(self, text='', style=None): """ return self._body.add_paragraph(text, style) - def add_picture(self, image_path_or_stream, width=None, height=None): + def add_picture( + self, image_path_or_stream, width=None, height=None, + position=None, margin=None, wrap=None + ): """ Return a new picture shape added in its own paragraph at the end of the document. The picture contains the image at @@ -76,7 +79,9 @@ def add_picture(self, image_path_or_stream, width=None, height=None): is often the case. """ run = self.add_paragraph().add_run() - return run.add_picture(image_path_or_stream, width, height) + return run.add_picture( + image_path_or_stream, width, height, position, margin, wrap + ) def add_section(self, start_type=WD_SECTION.NEW_PAGE): """ diff --git a/docx/enum/shape.py b/docx/enum/shape.py index f1d6ffd8c..03c6019b9 100644 --- a/docx/enum/shape.py +++ b/docx/enum/shape.py @@ -6,6 +6,8 @@ from __future__ import absolute_import, print_function, unicode_literals +from .base import Enumeration, EnumMember + class WD_INLINE_SHAPE_TYPE(object): """ @@ -18,4 +20,37 @@ class WD_INLINE_SHAPE_TYPE(object): SMART_ART = 15 NOT_IMPLEMENTED = -6 + WD_INLINE_SHAPE = WD_INLINE_SHAPE_TYPE + + +class WD_ANCHOR_SHAPE_TYPE(object): + """ + Corresponds to WdInlineShapeType enumeration + http://msdn.microsoft.com/en-us/library/office/ff192587.aspx + """ + CHART = 12 + LINKED_PICTURE = 4 + PICTURE = 3 + SMART_ART = 15 + NOT_IMPLEMENTED = -6 + + +WD_ANCHOR_SHAPE = WD_ANCHOR_SHAPE_TYPE + + +class WRAP_SHAPE_TYPE(Enumeration): + + __ms_name__ = '' + __members__ = ( + + EnumMember( + 'wrapSquareBothSides', 'bothSides', + 'A square wrapped shape with text wrapping on both sides' + ), + + EnumMember( + 'wrapTopAndBottom', '', + 'A square on its own line cleared on left and right' + ) + ) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 528b1eac7..d0f9c7541 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -93,25 +93,29 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:type', CT_SectType) from .shape import ( - CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, + CT_Anchor, CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, CT_GraphicalObjectData, CT_Inline, CT_NonVisualDrawingProps, CT_Picture, - CT_PictureNonVisual, CT_Point2D, CT_PositiveSize2D, CT_ShapeProperties, - CT_Transform2D + CT_PictureNonVisual, CT_Point2D, CT_PosH, CT_PositiveSize2D, + CT_ShapeProperties, CT_Transform2D, CT_WrapSquare, CT_WrapTopAndBottom ) -register_element_cls('a:blip', CT_Blip) -register_element_cls('a:ext', CT_PositiveSize2D) -register_element_cls('a:graphic', CT_GraphicalObject) -register_element_cls('a:graphicData', CT_GraphicalObjectData) -register_element_cls('a:off', CT_Point2D) -register_element_cls('a:xfrm', CT_Transform2D) -register_element_cls('pic:blipFill', CT_BlipFillProperties) -register_element_cls('pic:cNvPr', CT_NonVisualDrawingProps) -register_element_cls('pic:nvPicPr', CT_PictureNonVisual) -register_element_cls('pic:pic', CT_Picture) -register_element_cls('pic:spPr', CT_ShapeProperties) -register_element_cls('wp:docPr', CT_NonVisualDrawingProps) -register_element_cls('wp:extent', CT_PositiveSize2D) -register_element_cls('wp:inline', CT_Inline) +register_element_cls('a:blip', CT_Blip) +register_element_cls('a:ext', CT_PositiveSize2D) +register_element_cls('a:graphic', CT_GraphicalObject) +register_element_cls('a:graphicData', CT_GraphicalObjectData) +register_element_cls('a:off', CT_Point2D) +register_element_cls('a:xfrm', CT_Transform2D) +register_element_cls('pic:blipFill', CT_BlipFillProperties) +register_element_cls('pic:cNvPr', CT_NonVisualDrawingProps) +register_element_cls('pic:nvPicPr', CT_PictureNonVisual) +register_element_cls('pic:pic', CT_Picture) +register_element_cls('pic:spPr', CT_ShapeProperties) +register_element_cls('wp:docPr', CT_NonVisualDrawingProps) +register_element_cls('wp:extent', CT_PositiveSize2D) +register_element_cls('wp:inline', CT_Inline) +register_element_cls('wp:anchor', CT_Anchor) +register_element_cls('wp:positionH', CT_PosH) +register_element_cls('wp:wrapSquare', CT_WrapSquare) +register_element_cls('wp:wrapTopAndBottom', CT_WrapTopAndBottom) from .styles import CT_LatentStyles, CT_LsdException, CT_Style, CT_Styles register_element_cls('w:basedOn', CT_String) diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index 77ca7db8a..9fad76903 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -8,12 +8,13 @@ from .ns import nsdecls from .simpletypes import ( ST_Coordinate, ST_DrawingElementId, ST_PositiveCoordinate, - ST_RelationshipId, XsdString, XsdToken + ST_RelationshipId, XsdString, XsdStringEnumeration, XsdToken ) from .xmlchemy import ( BaseOxmlElement, OneAndOnlyOne, OptionalAttribute, RequiredAttribute, ZeroOrOne ) +from ..enum.shape import WRAP_SHAPE_TYPE class CT_Blip(BaseOxmlElement): @@ -34,6 +35,37 @@ class CT_BlipFillProperties(BaseOxmlElement): )) +class ST_WrapText(XsdStringEnumeration): + """ + Valid values for `wrapText/@val`. + """ + BOTHSIDES = 'bothSides' + + _members = (BOTHSIDES,) + + +class ST_RelFromH(XsdStringEnumeration): + """ + Valid values for `relativeFrom/@val` in CT_PosH. + """ + MARGIN = 'margin' + CHARACTER = 'character' + + +class CT_WrapSquare(BaseOxmlElement): + """ + ```` element for wrapping text + around a shape + """ + wrapText = RequiredAttribute('wrapText', ST_WrapText) + + +class CT_WrapTopAndBottom(BaseOxmlElement): + """ + ```` element for setting image on its own. + """ + + class CT_GraphicalObject(BaseOxmlElement): """ ```` element, container for a DrawingML object @@ -49,6 +81,16 @@ class CT_GraphicalObjectData(BaseOxmlElement): uri = RequiredAttribute('uri', XsdToken) +class CT_PosH(BaseOxmlElement): + """ + ```` for setting how shapes are + horizontally positioned. + """ + relativeFrom = RequiredAttribute('relativeFrom', ST_RelFromH) + align = ZeroOrOne('wp:align') + posOffset = ZeroOrOne('wp:posOffset') + + class CT_Inline(BaseOxmlElement): """ ```` element, container for an inline shape. @@ -58,7 +100,7 @@ class CT_Inline(BaseOxmlElement): graphic = OneAndOnlyOne('a:graphic') @classmethod - def new(cls, cx, cy, shape_id, pic): + def new(cls, cx, cy, shape_id, pic, position=None, margin=None, wrap=None): """ Return a new ```` element populated with the values passed as parameters. @@ -75,17 +117,27 @@ def new(cls, cx, cy, shape_id, pic): return inline @classmethod - def new_pic_inline(cls, shape_id, rId, filename, cx, cy): + def new_pic_inline( + cls, shape_id, rId, filename, cx, cy, + position=None, margin=None, wrap=None): """ Return a new `wp:inline` element containing the `pic:pic` element specified by the argument values. """ pic_id = 0 # Word doesn't seem to use this, but does not omit it pic = CT_Picture.new(pic_id, filename, rId, cx, cy) - inline = cls.new(cx, cy, shape_id, pic) + inline = cls.new(cx, cy, shape_id, pic, position, margin, wrap) inline.graphic.graphicData._insert_pic(pic) return inline + @classmethod + def new_pic( + cls, shape_id, rId, filename, cx, cy, + position=None, margin=None, wrap=None): + return cls.new_pic_inline( + shape_id, rId, filename, cx, cy, position, margin, wrap + ) + @classmethod def _inline_xml(cls): return ( @@ -102,6 +154,88 @@ def _inline_xml(cls): ) +class CT_Anchor(CT_Inline): + """ + ```` element, container for a floating shape. + """ + simplePos = OneAndOnlyOne('wp:simplePos') + positionH = OneAndOnlyOne('wp:positionH') + positionV = OneAndOnlyOne('wp:positionV') + effectExtent = OneAndOnlyOne('wp:effectExtent') + wrapSquare = ZeroOrOne('wp:wrapSquare') + wrapTopAndBottom = ZeroOrOne('wp:wrapTopAndBottom') + + @classmethod + def new(cls, cx, cy, shape_id, pic, position, margin=None, wrap=None): + """ + Return a new ```` element populated with the values passed + as parameters. + """ + anchor = parse_xml(cls._inline_xml()) + anchor.extent.cx = cx + anchor.extent.cy = cy + anchor.docPr.id = shape_id + anchor.docPr.name = 'Picture %d' % shape_id + anchor.graphic.graphicData.uri = ( + 'http://schemas.openxmlformats.org/drawingml/2006/picture' + ) + anchor.graphic.graphicData._insert_pic(pic) + positionH, positionV = position + + if positionH is None: + anchor.positionH.set('relativeFrom', 'character') + pos = anchor.positionH._add_posOffset() + pos.text = "0" + else: + anchor.positionH.set('relativeFrom', 'margin') + align = anchor.positionH._add_align() + align.text = positionH + + anchor.positionV.getchildren()[0].text = positionV + + if margin is not None: + anchor.set('distT', u"%d" % margin.get('top', 0)) + anchor.set('distR', u"%d" % margin.get('right', 0)) + anchor.set('distB', u"%d" % margin.get('bottom', 0)) + anchor.set('distL', u"%d" % margin.get('left', 0)) + + wrap_el = None + if wrap == WRAP_SHAPE_TYPE.wrapTopAndBottom: + wrap_el = anchor.get_or_add_wrapTopAndBottom() + elif wrap == WRAP_SHAPE_TYPE.wrapSquareBothSides: + wrap_el = anchor.get_or_add_wrapSquare() + wrap_el.wrapText = ST_WrapText.BOTHSIDES + + if wrap_el is not None: + anchor.insert_element_before(wrap_el, 'wp:effectExtent') + + return anchor + + @classmethod + def _inline_xml(cls): + return ( + '\n' + ' \n' + ' \n' + ' \n' + ' \n' + ' 0\n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + '' % (nsdecls('wp'), nsdecls('a'), nsdecls('a')) + ) + + class CT_NonVisualDrawingProps(BaseOxmlElement): """ Used for ```` element, and perhaps others. Specifies the id and @@ -160,7 +294,9 @@ def _pic_xml(cls): ' \n' ' \n' ' \n' - ' \n' + ' \n' + ' \n' + ' \n' ' \n' '' % nsdecls('pic', 'a', 'r') ) diff --git a/docx/parts/document.py b/docx/parts/document.py index 7a23e9a5e..fea181af9 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -12,7 +12,7 @@ from .numbering import NumberingPart from ..opc.constants import RELATIONSHIP_TYPE as RT from ..opc.part import XmlPart -from ..oxml.shape import CT_Inline +from ..oxml.shape import CT_Anchor, CT_Inline from ..shape import InlineShapes from ..shared import lazyproperty from .settings import SettingsPart @@ -89,10 +89,29 @@ def new_pic_inline(self, image_descriptor, width, height): specified by *image_descriptor* and scaled based on the values of *width* and *height*. """ + return self.new_pic(image_descriptor, width, height) + + def new_pic( + self, image_descriptor, width, height, + position=None, margin=None, wrap=None): + """ + Return a new `w:inline` or `w:anchor` element containing the image + specified by *image_descriptor* and scaled based on the values of + *width* and *height*. *position* is a tuple specifying the positionH + and positionV, setting this will float the image in `w:anchor`. *wrap* + can specify settings for the wrap, the default is: + ``wrapSquare wrapText='bothSides' + """ rId, image = self.get_or_add_image(image_descriptor) cx, cy = image.scaled_dimensions(width, height) shape_id, filename = self.next_id, image.filename - return CT_Inline.new_pic_inline(shape_id, rId, filename, cx, cy) + if position is None: + ShapeType = CT_Inline + else: + ShapeType = CT_Anchor + return ShapeType.new_pic( + shape_id, rId, filename, cx, cy, position, margin, wrap + ) @property def next_id(self): diff --git a/docx/shape.py b/docx/shape.py index e4f885d73..fbf2183cb 100644 --- a/docx/shape.py +++ b/docx/shape.py @@ -9,7 +9,7 @@ absolute_import, division, print_function, unicode_literals ) -from .enum.shape import WD_INLINE_SHAPE +from .enum.shape import WD_INLINE_SHAPE, WD_ANCHOR_SHAPE from .oxml.ns import nsmap from .shared import Parented @@ -101,3 +101,30 @@ def width(self): def width(self, cx): self._inline.extent.cx = cx self._inline.graphic.graphicData.pic.spPr.cx = cx + + +class AnchorShape(InlineShape): + """ + Proxy for an ```` element, representing the container for a + positioned graphical element. + """ + + @property + def type(self): + """ + The type of this anchored shape as a member of + ``docx.enum.shape.WD_INLINE_SHAPE``, e.g. ``LINKED_PICTURE``. + Read-only. + """ + graphicData = self._inline.graphic.graphicData + uri = graphicData.uri + if uri == nsmap['pic']: + blip = graphicData.pic.blipFill.blip + if blip.link is not None: + return WD_ANCHOR_SHAPE.LINKED_PICTURE + return WD_ANCHOR_SHAPE.PICTURE + if uri == nsmap['c']: + return WD_ANCHOR_SHAPE.CHART + if uri == nsmap['dgm']: + return WD_ANCHOR_SHAPE.SMART_ART + return WD_ANCHOR_SHAPE.NOT_IMPLEMENTED diff --git a/docx/text/run.py b/docx/text/run.py index 97d6da7db..0fd4df0e4 100644 --- a/docx/text/run.py +++ b/docx/text/run.py @@ -9,7 +9,7 @@ from ..enum.style import WD_STYLE_TYPE from ..enum.text import WD_BREAK from .font import Font -from ..shape import InlineShape +from ..shape import AnchorShape, InlineShape from ..shared import Parented @@ -46,7 +46,10 @@ def add_break(self, break_type=WD_BREAK.LINE): if clear is not None: br.clear = clear - def add_picture(self, image_path_or_stream, width=None, height=None): + def add_picture( + self, image_path_or_stream, width=None, height=None, + position=None, margin=None, wrap=None + ): """ Return an |InlineShape| instance containing the image identified by *image_path_or_stream*, added to the end of this run. @@ -58,10 +61,20 @@ def add_picture(self, image_path_or_stream, width=None, height=None): native size of the picture is calculated using the dots-per-inch (dpi) value specified in the image file, defaulting to 72 dpi if no value is specified, as is often the case. + *inline* boolean true if the picture is inline with text, + false if floated. """ - inline = self.part.new_pic_inline(image_path_or_stream, width, height) - self._r.add_drawing(inline) - return InlineShape(inline) + image = self.part.new_pic( + image_path_or_stream, width, height, position, margin, wrap + ) + self._r.add_drawing(image) + + if position is None: + ShapeType = InlineShape + else: + ShapeType = AnchorShape + + return ShapeType(image) def add_tab(self): """ diff --git a/tests/oxml/unitdata/dml.py b/tests/oxml/unitdata/dml.py index 84518f8b7..e10dbb323 100644 --- a/tests/oxml/unitdata/dml.py +++ b/tests/oxml/unitdata/dml.py @@ -7,6 +7,12 @@ from ...unitdata import BaseBuilder +class CT_AnchorBuilder(BaseBuilder): + __tag__ = 'wp:anchor' + __nspfxs__ = ('wp',) + __attrs__ = ('distT', 'distB', 'distL', 'distR') + + class CT_BlipBuilder(BaseBuilder): __tag__ = 'a:blip' __nspfxs__ = ('a',) @@ -195,6 +201,10 @@ def an_inline(): return CT_InlineBuilder() +def an_anchor(): + return CT_AnchorBuilder() + + def an_nvPicPr(): return CT_PictureNonVisualBuilder() diff --git a/tests/test_shape.py b/tests/test_shape.py index 105d2fa40..53cf37aa2 100644 --- a/tests/test_shape.py +++ b/tests/test_shape.py @@ -10,11 +10,12 @@ from docx.enum.shape import WD_INLINE_SHAPE from docx.oxml.ns import nsmap -from docx.shape import InlineShape, InlineShapes +from docx.shape import AnchorShape, InlineShape, InlineShapes from docx.shared import Length from .oxml.unitdata.dml import ( a_blip, a_blipFill, a_graphic, a_graphicData, a_pic, an_inline, + an_anchor, ) from .unitutil.cxml import element, xml from .unitutil.mock import loose_mock @@ -78,6 +79,71 @@ def inline_shapes_with_parent_(self, request): return inline_shapes, parent_ +class DescribeAncorShape(object): + + def it_knows_what_type_of_shape_it_is(self, shape_type_fixture): + shape, shape_type = shape_type_fixture + assert shape.type == shape_type + + @pytest.fixture(params=[ + 'embed pic', 'link pic', 'link+embed pic', 'chart', 'smart art', + 'not implemented' + ]) + def shape_type_fixture(self, request): + if request.param == 'embed pic': + inline = self._with_picture(embed=True) + shape_type = WD_INLINE_SHAPE.PICTURE + + elif request.param == 'link pic': + inline = self._with_picture(link=True) + shape_type = WD_INLINE_SHAPE.LINKED_PICTURE + + elif request.param == 'link+embed pic': + inline = self._with_picture(embed=True, link=True) + shape_type = WD_INLINE_SHAPE.LINKED_PICTURE + + elif request.param == 'chart': + inline = self._with_uri(nsmap['c']) + shape_type = WD_INLINE_SHAPE.CHART + + elif request.param == 'smart art': + inline = self._with_uri(nsmap['dgm']) + shape_type = WD_INLINE_SHAPE.SMART_ART + + elif request.param == 'not implemented': + inline = self._with_uri('foobar') + shape_type = WD_INLINE_SHAPE.NOT_IMPLEMENTED + + return AnchorShape(inline), shape_type + + def _with_picture(self, embed=False, link=False): + picture_ns = nsmap['pic'] + + blip_bldr = a_blip() + if embed: + blip_bldr.with_embed('rId1') + if link: + blip_bldr.with_link('rId2') + + image = ( + an_anchor().with_nsdecls('wp', 'r').with_child( + a_graphic().with_nsdecls().with_child( + a_graphicData().with_uri(picture_ns).with_child( + a_pic().with_nsdecls().with_child( + a_blipFill().with_child( + blip_bldr))))) + ).element + return image + + def _with_uri(self, uri): + inline = ( + an_anchor().with_nsdecls('wp').with_child( + a_graphic().with_nsdecls().with_child( + a_graphicData().with_uri(uri))) + ).element + return inline + + class DescribeInlineShape(object): def it_knows_what_type_of_shape_it_is(self, shape_type_fixture):