diff --git a/.gitignore b/.gitignore index e24445137..f3e7b3985 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /dist/ /docs/.build/ /*.egg-info +.pytest_cache/ *.pyc .pytest_cache/ _scratch/ diff --git a/docs/conf.py b/docs/conf.py index 1a988453a..4e8337899 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath('..')) +sys.path.insert(0, os.path.abspath("..")) from docx import __version__ # noqa @@ -31,28 +31,28 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.intersphinx', - 'sphinx.ext.todo', - 'sphinx.ext.coverage', - 'sphinx.ext.viewcode' + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.todo", + "sphinx.ext.coverage", + "sphinx.ext.viewcode", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'python-docx' -copyright = u'2013, Steve Canny' +project = u"python-docx" +copyright = u"2013, Steve Canny" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -77,6 +77,10 @@ .. |_Body| replace:: :class:`._Body` +.. |Bookmark| replace:: :class:`.Bookmark` + +.. |Bookmarks| replace:: :class:`.Bookmarks` + .. |_Cell| replace:: :class:`._Cell` .. |_CharacterStyle| replace:: :class:`._CharacterStyle` @@ -89,6 +93,8 @@ .. |_Columns| replace:: :class:`._Columns` +.. |CommentsPart| replace:: :class:`.CommentsPart` + .. |CoreProperties| replace:: :class:`.CoreProperties` .. |datetime| replace:: :class:`.datetime.datetime` @@ -101,6 +107,8 @@ .. |Emu| replace:: :class:`.Emu` +.. |EndnotesPart| replace:: :class:`.EndnotesPart` + .. |False| replace:: :class:`False` .. |float| replace:: :class:`.float` @@ -111,6 +119,8 @@ .. |FooterPart| replace:: :class:`.FooterPart` +.. |FootnotesPart| replace:: :class:`.FootnotesPart` + .. |_Header| replace:: :class:`._Header` .. |HeaderPart| replace:: :class:`.HeaderPart` @@ -193,7 +203,7 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['.build'] +exclude_patterns = [".build"] # The reST default role (used for this markup: `text`) to use for all # documents. @@ -211,7 +221,7 @@ # show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] @@ -221,7 +231,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'armstrong' +html_theme = "armstrong" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -229,7 +239,7 @@ # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -html_theme_path = ['_themes'] +html_theme_path = ["_themes"] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". @@ -250,7 +260,7 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. @@ -263,8 +273,7 @@ # Custom sidebar templates, maps document names to template names. # html_sidebars = {} html_sidebars = { - '**': ['localtoc.html', 'relations.html', 'sidebarlinks.html', - 'searchbox.html'] + "**": ["localtoc.html", "relations.html", "sidebarlinks.html", "searchbox.html"] } # Additional templates that should be rendered to pages, maps page names to @@ -298,7 +307,7 @@ # html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'python-docxdoc' +htmlhelp_basename = "python-docxdoc" # -- Options for LaTeX output ----------------------------------------------- @@ -306,10 +315,8 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. # 'preamble': '', } @@ -321,8 +328,7 @@ # author, # documentclass [howto/manual]). latex_documents = [ - ('index', 'python-docx.tex', u'python-docx Documentation', - u'Steve Canny', 'manual'), + ("index", "python-docx.tex", u"python-docx Documentation", u"Steve Canny", "manual") ] # The name of an image file (relative to this directory) to place at the top of @@ -351,8 +357,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'python-docx', u'python-docx Documentation', - [u'Steve Canny'], 1) + ("index", "python-docx", u"python-docx Documentation", [u"Steve Canny"], 1) ] # If true, show URL addresses after external links. @@ -365,9 +370,15 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'python-docx', u'python-docx Documentation', - u'Steve Canny', 'python-docx', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "python-docx", + u"python-docx Documentation", + u"Steve Canny", + "python-docx", + "One line description of project.", + "Miscellaneous", + ) ] # Documents to append as an appendix to all manuals. @@ -381,4 +392,4 @@ # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'http://docs.python.org/3/': None} +intersphinx_mapping = {"http://docs.python.org/3/": None} diff --git a/docs/dev/analysis/features/bookmarks.rst b/docs/dev/analysis/features/bookmarks.rst new file mode 100644 index 000000000..6a4723f84 --- /dev/null +++ b/docs/dev/analysis/features/bookmarks.rst @@ -0,0 +1,347 @@ + +Bookmarks +========= + +WordprocessingML allows zero or more *bookmark* items to be specified at an +arbitrary location in a document. + +A bookmark consists of a `w:bookmarkStart` element identified with both +a `w:id` and `w:name` attribute, and a matching `w:bookmarkEnd` element +having the same `w:id` value. + +Taken as a whole (matching element pair) the bookmark has both an id and +a name. The bookmark appears in the Word UI by its name; the presence and +uniqueness of the name are both required. While used to match starts and +ends, the id value is not stable across saves in the Word UI. The bookmark +name should be used as the key value for lookups. + +A bookmark delimits an arbitrary contiguous sequence of text in a document. +It's start and end can be at either the block level (between paragraphs +and/or tables) or in-between runs (between individual characters). A bookmark +can also appear in a table. + +Among the applications of bookmarks in Word is their use in captions and +cross-references. + + +Protocol +-------- + +.. highlight:: python + +Adding a bookmark:: + + >>> bookmarks = document.bookmarks + >>> bookmarks + + >>> len(bookmarks) + 0 + >>> bookmark = document.start_bookmark('Target') + >>> bookmark.name + 'Target' + >>> bookmark.id + 1 + >>> len(bookmarks) # doesn't count until it's closed + 0 + + >>> document.add_paragraph() # etc. ... + + >>> document.end_bookmark(bookmark) + >>> len(bookmarks) + 1 + >>> bookmarks.get('Target') + + >>> bookmarks.get_by_id(1) + + >>> bookmarks[0] + + + # A bookmark can be deleted: + >>> len(bookmarks) + >>> 2 + >>> bookmark = bookmarks[0] + >>> bookmark.delete() + >>> len(bookmarks) + >>> 1 + + +Word Behavior +------------- + +* The Word UI enforces the uniqueness of bookmark names. + +* A bookmark having the same name as a prior bookmark (in document order) is + ignored by Word. + +* An unclosed bookmark (`w:bookmarkStart` without matching `w:bookmarkEnd`) + is ignored by Word. + +* A "reversed" bookmark (`w:bookmarkEnd` appears before matching + `w:bookmarkStart`) is ignored by Word and removed on the next save (by + Word). + +* Word will change bookmark ids (while keeping start and end consistent) at + its convenience. A bookmark id is not a stable key across document saves + (in Word). + +* In general, referents to a bookmark use the bookmark *name* as the key. + This makes sense as the id is not a durable key. + +* A bookmark can be *hidden*, which occurs for example when cross-references + are inserted into the document. + +* As bookmarks need to be unique over all document stories, a check should + be done for uniqueness. (The word API replaces the bookmark by a new one + when a duplicate bookmarkname is used to insert a new bookmark. + The word editor removes duplicate bookmarks.) + +* Bookmarks may overlap i.e. A new bookmark is started as the previous + one is not yet ended. + +* Bookmarks may be nested i.e. a bookmark may exists within the limits + of another bookmark. + +* A bookmark can be added in five different document parts: Body, Header, + Footer, Footnote and Endnote. + +* As bookmarks can be added in at different locations as well as different + document parts, the bookmarkStart and bookmarkEnd elements should be added + to different complex types: CT_Body, CT_P and CT_Tbl, as well as CT_HdrFtr + and CT_FtnEdn. + + +XML Semantics +------------- + +* The `w:bookmarkStart` element can use optional `w:colFirst` and `w:colLast` + elements to bookmark specific parts of a table. If used, both should appear. + + +Specimen XML +------------ + +.. highlight:: xml + +:: + + + + Foo + + + + bar + + + + + Bar + + + + foo + + + + +MS API Protocol +--------------- + +The MS API defines a `Bookmarks` object which is a collection of +`Bookmark objects` + +Bookmarks object: + +https://msdn.microsoft.com/en-us/vba/word-vba/articles/bookmarks-object-word + +Methods: +* Bookmarks.Exists(name) - Checks if bookmark name exists in document. +* Bookmarks.Item(index) - Returns bookmark based on id or name. + +Properties: +* Bookmarks.Count - Number of bookmarks + +Bookmark objects: +https://msdn.microsoft.com/en-us/vba/word-vba/articles/bookmark-object-word + +Methods: +* Bookmark.Delete() - Removing the two elements from the document + +Properties: +* Bookmark.Column (boolean) - True if bookmark is inside a table Column +* Bookmark.Empty (boolean) - True if the specified bookmark is Empty +* Bookmark.Name - Return name of bookmark. + +Schema excerpt +-------------- + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index b32bf5cc1..8859450b8 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,7 @@ Feature Analysis .. toctree:: :titlesonly: + features/bookmarks features/header features/settings features/text/index diff --git a/docx/__init__.py b/docx/__init__.py index 59756c021..d29f6ecff 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -12,6 +12,8 @@ from docx.opc.parts.coreprops import CorePropertiesPart from docx.parts.document import DocumentPart +from docx.parts.endnotes import EndnotesPart +from docx.parts.footnotes import FootnotesPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.image import ImagePart from docx.parts.numbering import NumberingPart @@ -28,7 +30,9 @@ def part_class_selector(content_type, reltype): PartFactory.part_class_selector = part_class_selector PartFactory.part_type_for[CT.OPC_CORE_PROPERTIES] = CorePropertiesPart PartFactory.part_type_for[CT.WML_DOCUMENT_MAIN] = DocumentPart +PartFactory.part_type_for[CT.WML_ENDNOTES] = EndnotesPart PartFactory.part_type_for[CT.WML_FOOTER] = FooterPart +PartFactory.part_type_for[CT.WML_FOOTNOTES] = FootnotesPart PartFactory.part_type_for[CT.WML_HEADER] = HeaderPart PartFactory.part_type_for[CT.WML_NUMBERING] = NumberingPart PartFactory.part_type_for[CT.WML_SETTINGS] = SettingsPart @@ -38,7 +42,9 @@ def part_class_selector(content_type, reltype): CT, CorePropertiesPart, DocumentPart, + EndnotesPart, FooterPart, + FootnotesPart, HeaderPart, NumberingPart, PartFactory, diff --git a/docx/blkcntnr.py b/docx/blkcntnr.py index a80903e52..097ddcca2 100644 --- a/docx/blkcntnr.py +++ b/docx/blkcntnr.py @@ -8,8 +8,9 @@ from __future__ import absolute_import, division, print_function, unicode_literals +from docx.bookmark import _Bookmark from docx.oxml.table import CT_Tbl -from docx.shared import Parented +from docx.shared import lazyproperty, Parented from docx.text.paragraph import Paragraph @@ -25,7 +26,7 @@ def __init__(self, element, parent): super(BlockItemContainer, self).__init__(parent) self._element = element - def add_paragraph(self, text='', style=None): + def add_paragraph(self, text="", style=None): """ Return a paragraph newly added to the end of the content in this container, having *text* in a single run if present, and having @@ -46,10 +47,17 @@ def add_table(self, rows, cols, width): distributed between the table columns. """ from .table import Table + tbl = CT_Tbl.new_tbl(rows, cols, width) self._element._insert_tbl(tbl) return Table(tbl, self) + def end_bookmark(self, bookmark): + """Return `bookmark` after closing it after last block item in container.""" + if bookmark.is_closed: + raise ValueError("bookmark already closed") + return bookmark.close(self._element.add_bookmarkEnd(bookmark.id)) + @property def paragraphs(self): """ @@ -58,6 +66,20 @@ def paragraphs(self): """ return [Paragraph(p, self) for p in self._element.p_lst] + def start_bookmark(self, name): + """Return newly-added |_Bookmark| object identified by `name`. + + The returned bookmark is anchored at the end of this block-item container, for + example, after the last paragraph in the document when the document body is the + block-item container. + """ + if name in self._bookmarks: + raise KeyError("Document already contains bookmark with name %s" % name) + + return _Bookmark( + (self._element.add_bookmarkStart(name, self._bookmarks.next_id), None) + ) + @property def tables(self): """ @@ -65,6 +87,7 @@ def tables(self): Read-only. """ from .table import Table + return [Table(tbl, self) for tbl in self._element.tbl_lst] def _add_paragraph(self): @@ -73,3 +96,8 @@ def _add_paragraph(self): container. """ return Paragraph(self._element.add_p(), self) + + @lazyproperty + def _bookmarks(self): + """Global |Bookmarks| object for overall document.""" + return self.part.bookmarks diff --git a/docx/bookmark.py b/docx/bookmark.py new file mode 100644 index 000000000..c389a5ad2 --- /dev/null +++ b/docx/bookmark.py @@ -0,0 +1,215 @@ +# encoding: utf-8 + +"""Objects related to bookmarks.""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +from itertools import chain + +from docx.compat import Sequence +from docx.oxml.ns import qn +from docx.shared import lazyproperty + + +class Bookmarks(Sequence): + """Sequence of |Bookmark| objects. + + This object has mixed semantics. As a sequence, it supports indexed access + (including slices), `len()`, and iteration (which will perform significantly + better than repeated indexed access). It also supports some `dict` semantics on + bookmark name. Specifically, the `in` operator can be used to detect the presence of + a bookmark by name (e.g. `if name in bookmarks`) and it has a `get()` method that + allows a bookmark to be retrieved by name. + """ + + def __init__(self, document_part): + self._document_part = document_part + + def __contains__(self, name): + """Supports `in` operator to test for presence of bookmark by `name`.""" + for bookmark in self: + if bookmark.name == name: + return True + return False + + def __getitem__(self, idx): + """Supports indexed and sliced access.""" + bookmark_pairs = self._finder.bookmark_pairs + if isinstance(idx, slice): + return [_Bookmark(pair) for pair in bookmark_pairs[idx]] + return _Bookmark(bookmark_pairs[idx]) + + def __iter__(self): + """Supports iteration.""" + return (_Bookmark(pair) for pair in self._finder.bookmark_pairs) + + def __len__(self): + return len(self._finder.bookmark_pairs) + + def get(self, name): + """Get bookmark based on its name. + + Raises `KeyError` if no bookmark with `name` is present in collection. + """ + for bookmark in self: + if bookmark.name == name: + return bookmark + raise KeyError("Requested bookmark not found.") + + @property + def next_id(self): + """Return the next available int bookmark-id, unique in document-wide scope.""" + bookmark_ids = tuple(bookmark.id for bookmark in self) + if not bookmark_ids: + return 1 + return max(bookmark_ids) + 1 + + @lazyproperty + def _finder(self): + """_DocumentBookmarkFinder instance for this document.""" + return _DocumentBookmarkFinder(self._document_part) + + +class _Bookmark(object): + """Proxy for a (w:bookmarkStart, w:bookmarkEnd) element pair.""" + + def __init__(self, bookmark_pair): + self._bookmarkStart, self._bookmarkEnd = bookmark_pair + + def __eq__(self, other): + if not isinstance(other, _Bookmark): + return False + return ( + self._bookmarkStart is other._bookmarkStart + and self._bookmarkEnd is other._bookmarkEnd + ) + + def close(self, bookmarkEnd): + """Return self after setting end marker to `bookmarkEnd`. + + Raises ValueError if this bookmark is already closed or if `id` attribute of + `bookmarkEnd` does not match that of the `bookmarkStart` element. + """ + if self._bookmarkEnd is not None: + raise ValueError("bookmark already closed") + if bookmarkEnd.id != self._bookmarkStart.id: + raise ValueError("end id does not match start id") + self._bookmarkEnd = bookmarkEnd + return self + + @property + def id(self): + """Provides access to the bookmark id.""" + return self._bookmarkStart.id + + @property + def is_closed(self): + """True if this bookmark has both a start and end element.""" + return self._bookmarkEnd is not None + + @property + def name(self): + """Provides access to the bookmark name.""" + return self._bookmarkStart.name + + +class _DocumentBookmarkFinder(object): + """Provides access to bookmark oxml elements in an overall document.""" + + def __init__(self, document_part): + self._document_part = document_part + + @property + def bookmark_pairs(self): + """List of (bookmarkStart, bookmarkEnd) element pairs for document. + + The return value is a list of two-tuples (pairs) each containing + a start and its matching end element. + + All story parts of the document are searched, including the main + document story, headers, footers, footnotes, and endnotes. The order + of part searching is not guaranteed, but bookmarks appear in document + order within a particular part. Only well-formed bookmarks appear. + Any open bookmarks (start but no end), reversed bookmarks (end before + start), or duplicate (name same as prior bookmark) bookmarks are + ignored. + """ + return list( + chain( + *( + _PartBookmarkFinder.iter_start_end_pairs(part) + for part in self._document_part.iter_story_parts() + ) + ) + ) + + +class _PartBookmarkFinder(object): + """Provides access to bookmark oxml elements in a story part.""" + + def __init__(self, part): + self._part = part + + @classmethod + def iter_start_end_pairs(cls, part): + """Generate each (bookmarkStart, bookmarkEnd) in *part*.""" + return cls(part)._iter_start_end_pairs() + + def _iter_start_end_pairs(self): + """Generate each (bookmarkStart, bookmarkEnd) in this part.""" + for idx, bookmarkStart in self._iter_starts(): + bookmarkEnd = self._matching_end(bookmarkStart, idx) + # ---skip open pairs--- + if bookmarkEnd is None: + continue + # ---skip duplicate names--- + if self._name_already_used(bookmarkStart.name): + continue + yield (bookmarkStart, bookmarkEnd) + + @lazyproperty + def _all_starts_and_ends(self): + """list of all `w:bookmarkStart` and `w:bookmarkEnd` elements in part. + + Elements appear in document order. + """ + return self._part.element.xpath("//w:bookmarkStart|//w:bookmarkEnd") + + def _iter_starts(self): + """Generate (idx, bookmarkStart) elements in story. + + The *idx* value indicates the location of the bookmarkStart element + among all the bookmarkStart and bookmarkEnd elements in the story. + """ + for idx, element in enumerate(self._all_starts_and_ends): + if element.tag == qn("w:bookmarkStart"): + yield idx, element + + def _matching_end(self, bookmarkStart, idx): + """Return the `w:bookmarkEnd` element corresponding to *bookmarkStart*. + + Returns None if no `w:bookmarkEnd` with matching id value is found. *idx* is the + offset of *bookmarkStart* in the sequence of start and end elements in this + story. + """ + for element in self._all_starts_and_ends[idx + 1 :]: + # ---skip bookmark starts--- + if element.tag == qn("w:bookmarkStart"): + continue + bookmarkEnd = element + if bookmarkEnd.id == bookmarkStart.id: + return bookmarkEnd + return None + + def _name_already_used(self, name): + """Return True if bookmark *name* was already encountered, False otherwise.""" + names_so_far = self._names_so_far + if name in names_so_far: + return True + names_so_far.add(name) + return False + + @lazyproperty + def _names_so_far(self): + """set composed to track bookmark names encountered in document traversal.""" + return set() diff --git a/docx/compat.py b/docx/compat.py index 1e3012d95..82b65ba4b 100644 --- a/docx/compat.py +++ b/docx/compat.py @@ -12,9 +12,13 @@ # Python 3 versions # =========================================================================== +if sys.version_info >= (3, 3): + from collections.abc import Sequence +else: + from collections import Sequence # noqa + if sys.version_info >= (3, 0): - from collections.abc import Sequence from io import BytesIO def is_string(obj): @@ -29,7 +33,6 @@ def is_string(obj): else: - from collections import Sequence # noqa from StringIO import StringIO as BytesIO # noqa def is_string(obj): diff --git a/docx/document.py b/docx/document.py index 6493c458b..67df95587 100644 --- a/docx/document.py +++ b/docx/document.py @@ -5,10 +5,11 @@ from __future__ import absolute_import, division, print_function, unicode_literals from docx.blkcntnr import BlockItemContainer + from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK from docx.section import Section, Sections -from docx.shared import ElementProxy, Emu +from docx.shared import ElementProxy, Emu, lazyproperty class Document(ElementProxy): @@ -18,7 +19,7 @@ class Document(ElementProxy): a document. """ - __slots__ = ('_part', '__body') + __slots__ = ("__body", "_bookmarks", "_part") def __init__(self, element, part): super(Document, self).__init__(element) @@ -44,7 +45,7 @@ def add_page_break(self): paragraph.add_run().add_break(WD_BREAK.PAGE) return paragraph - def add_paragraph(self, text='', style=None): + def add_paragraph(self, text="", style=None): """ Return a paragraph newly added to the end of the document, populated with *text* and having paragraph style *style*. *text* can contain @@ -93,6 +94,16 @@ def add_table(self, rows, cols, style=None): table.style = style return table + @lazyproperty + def bookmarks(self): + """|Bookmarks| object providing access to |Bookmark| objects. + + A bookmark may exist in the main document story, but also in headers, + footers, footnotes or endnotes. This collection contains all + bookmarks defined in any of these parts. + """ + return self._part.bookmarks + @property def core_properties(self): """ @@ -101,6 +112,10 @@ def core_properties(self): """ return self._part.core_properties + def end_bookmark(self, bookmark): + """Return `bookmark` after closing it at end of this document.""" + return self._body.end_bookmark(bookmark) + @property def inline_shapes(self): """ @@ -147,6 +162,13 @@ def settings(self): """ return self._part.settings + def start_bookmark(self, name): + """Return _Bookmark object identified by `name`. + + The returned bookmark is anchored at the end of this document. + """ + return self._body.start_bookmark(name) + @property def styles(self): """ @@ -172,9 +194,7 @@ def _block_width(self): space between the margins of the last section of this document. """ section = self.sections[-1] - return Emu( - section.page_width - section.left_margin - section.right_margin - ) + return Emu(section.page_width - section.left_margin - section.right_margin) @property def _body(self): @@ -191,6 +211,7 @@ class _Body(BlockItemContainer): Proxy for ```` element in this document, having primarily a container role. """ + def __init__(self, body_elm, parent): super(_Body, self).__init__(body_elm, parent) self._body = body_elm diff --git a/docx/enum/base.py b/docx/enum/base.py index 36764b1a6..f08bb814f 100644 --- a/docx/enum/base.py +++ b/docx/enum/base.py @@ -72,8 +72,8 @@ def _member_def(self, member): """ member_docstring = textwrap.dedent(member.docstring).strip() member_docstring = textwrap.fill( - member_docstring, width=78, initial_indent=' '*4, - subsequent_indent=' '*4 + member_docstring, width=78, initial_indent=' ' * 4, + subsequent_indent=' ' * 4 ) return '%s\n%s\n' % (member.name, member_docstring) @@ -103,7 +103,7 @@ def _page_title(self): The title for the documentation page, formatted as code (surrounded in double-backtics) and underlined with '=' characters """ - title_underscore = '=' * (len(self._clsname)+4) + title_underscore = '=' * (len(self._clsname) + 4) return '``%s``\n%s' % (self._clsname, title_underscore) diff --git a/docx/image/jpeg.py b/docx/image/jpeg.py index 8a263b6c5..da0116d8b 100644 --- a/docx/image/jpeg.py +++ b/docx/image/jpeg.py @@ -208,12 +208,12 @@ def next(self, start): # skip over any non-\xFF bytes position = self._offset_of_next_ff_byte(start=position) # skip over any \xFF padding bytes - position, byte_ = self._next_non_ff_byte(start=position+1) + position, byte_ = self._next_non_ff_byte(start=position + 1) # 'FF 00' sequence is not a marker, start over if found if byte_ == b'\x00': continue # this is a marker, gather return values and break out of scan - marker_code, segment_offset = byte_, position+1 + marker_code, segment_offset = byte_, position + 1 break return marker_code, segment_offset @@ -438,7 +438,7 @@ def _is_non_Exif_APP1_segment(cls, stream, offset): Exif segment, as determined by the ``'Exif\x00\x00'`` signature at offset 2 in the segment. """ - stream.seek(offset+2) + stream.seek(offset + 2) exif_signature = stream.read(6) return exif_signature != b'Exif\x00\x00' @@ -449,8 +449,8 @@ def _tiff_from_exif_segment(cls, stream, offset, segment_length): *segment_length* at *offset* in *stream*. """ # wrap full segment in its own stream and feed to Tiff() - stream.seek(offset+8) - segment_bytes = stream.read(segment_length-8) + stream.seek(offset + 8) + segment_bytes = stream.read(segment_length - 8) substream = BytesIO(segment_bytes) return Tiff.from_stream(substream) diff --git a/docx/image/tiff.py b/docx/image/tiff.py index c38242360..7896d206d 100644 --- a/docx/image/tiff.py +++ b/docx/image/tiff.py @@ -200,7 +200,7 @@ def iter_entries(self): directory. """ for idx in range(self._entry_count): - dir_entry_offset = self._offset + 2 + (idx*12) + dir_entry_offset = self._offset + 2 + (idx * 12) ifd_entry = _IfdEntryFactory(self._stream_rdr, dir_entry_offset) yield ifd_entry @@ -291,7 +291,7 @@ def _parse_value(cls, stream_rdr, offset, value_count, value_offset): The length of the string, including a terminating '\x00' (NUL) character, is in *value_count*. """ - return stream_rdr.read_str(value_count-1, value_offset) + return stream_rdr.read_str(value_count - 1, value_offset) class _ShortIfdEntry(_IfdEntry): diff --git a/docx/opc/part.py b/docx/opc/part.py index 928d3c183..de1f6ddda 100644 --- a/docx/opc/part.py +++ b/docx/opc/part.py @@ -74,6 +74,17 @@ def drop_rel(self, rId): if self._rel_ref_count(rId) < 2: del self.rels[rId] + def iter_parts_related_by(self, reltypes): + """Generate each part related to this by one of *reltypes*. + + *reltypes* must be a container; `set` is convenient but list or other + sequence types work fine. + """ + return ( + rel.target_part for rel in self.rels.values() + if rel.reltype in reltypes + ) + @classmethod def load(cls, partname, content_type, blob, package): return cls(partname, content_type, blob, package) diff --git a/docx/opc/rel.py b/docx/opc/rel.py index 7dba2af8e..c5bdc9203 100644 --- a/docx/opc/rel.py +++ b/docx/opc/rel.py @@ -12,9 +12,8 @@ class Relationships(dict): - """ - Collection object for |_Relationship| instances, having list semantics. - """ + """Collection object for |_Relationship| instances, having dict semantics""" + def __init__(self, baseURI): super(Relationships, self).__init__() self._baseURI = baseURI @@ -125,16 +124,15 @@ def _next_rId(self): Next available rId in collection, starting from 'rId1' and making use of any gaps in numbering, e.g. 'rId2' for rIds ['rId1', 'rId3']. """ - for n in range(1, len(self)+2): + for n in range(1, len(self) + 2): rId_candidate = 'rId%d' % n # like 'rId19' if rId_candidate not in self: return rId_candidate class _Relationship(object): - """ - Value object for relationship to part. - """ + """Value object for relationship to part""" + def __init__(self, rId, reltype, target, baseURI, external=False): super(_Relationship, self).__init__() self._rId = rId @@ -157,9 +155,12 @@ def rId(self): @property def target_part(self): + """|Part| or subclass this relationship links to.""" if self._is_external: - raise ValueError("target_part property on _Relationship is undef" - "ined when target mode is External") + raise ValueError( + "target_part property on _Relationship is undefined when target mode " + "is External" + ) return self._target @property diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 093c1b45b..1fce04d2a 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -35,7 +35,7 @@ def register_element_cls(tag, cls): element with matching *tag*. *tag* is a string of the form ``nspfx:tagroot``, e.g. ``'w:document'``. """ - nspfx, tagroot = tag.split(':') + nspfx, tagroot = tag.split(":") namespace = element_class_lookup.get_namespace(nsmap[nspfx]) namespace[tagroot] = cls @@ -55,38 +55,52 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): nsptag = NamespacePrefixedTag(nsptag_str) if nsdecls is None: nsdecls = nsptag.nsmap - return oxml_parser.makeelement( - nsptag.clark_name, attrib=attrs, nsmap=nsdecls - ) + return oxml_parser.makeelement(nsptag.clark_name, attrib=attrs, nsmap=nsdecls) # =========================================================================== # custom element class mappings # =========================================================================== -from .shared import CT_DecimalNumber, CT_OnOff, CT_String # noqa +from docx.oxml.shared import CT_DecimalNumber, CT_OnOff, CT_String # noqa + register_element_cls("w:evenAndOddHeaders", CT_OnOff) register_element_cls("w:titlePg", CT_OnOff) +from docx.oxml.bookmark import CT_Bookmark, CT_MarkupRange # noqa + +register_element_cls("w:bookmarkEnd", CT_MarkupRange) +register_element_cls("w:bookmarkStart", CT_Bookmark) + +from docx.oxml.coreprops import CT_CoreProperties # noqa + +register_element_cls("cp:coreProperties", CT_CoreProperties) + +from docx.oxml.document import CT_Body, CT_Document # noqa + +register_element_cls("w:body", CT_Body) +register_element_cls("w:document", CT_Document) + +from docx.oxml.endnotes import CT_Endnotes # noqa -from .coreprops import CT_CoreProperties # noqa -register_element_cls('cp:coreProperties', CT_CoreProperties) +register_element_cls('w:endnotes', CT_Endnotes) -from .document import CT_Body, CT_Document # noqa -register_element_cls('w:body', CT_Body) -register_element_cls('w:document', CT_Document) +from docx.oxml.footnotes import CT_Footnotes # noqa -from .numbering import CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr # noqa -register_element_cls('w:abstractNumId', CT_DecimalNumber) -register_element_cls('w:ilvl', CT_DecimalNumber) -register_element_cls('w:lvlOverride', CT_NumLvl) -register_element_cls('w:num', CT_Num) -register_element_cls('w:numId', CT_DecimalNumber) -register_element_cls('w:numPr', CT_NumPr) -register_element_cls('w:numbering', CT_Numbering) -register_element_cls('w:startOverride', CT_DecimalNumber) +register_element_cls('w:footnotes', CT_Footnotes) -from .section import ( # noqa +from docx.oxml.numbering import CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr # noqa + +register_element_cls("w:abstractNumId", CT_DecimalNumber) +register_element_cls("w:ilvl", CT_DecimalNumber) +register_element_cls("w:lvlOverride", CT_NumLvl) +register_element_cls("w:num", CT_Num) +register_element_cls("w:numId", CT_DecimalNumber) +register_element_cls("w:numPr", CT_NumPr) +register_element_cls("w:numbering", CT_Numbering) +register_element_cls("w:startOverride", CT_DecimalNumber) + +from docx.oxml.section import ( # noqa CT_HdrFtr, CT_HdrFtrRef, CT_PageMar, @@ -94,6 +108,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): CT_SectPr, CT_SectType, ) + register_element_cls("w:footerReference", CT_HdrFtrRef) register_element_cls("w:ftr", CT_HdrFtr) register_element_cls("w:hdr", CT_HdrFtr) @@ -104,9 +119,10 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls("w:type", CT_SectType) from .settings import CT_Settings # noqa + register_element_cls("w:settings", CT_Settings) -from .shape import ( # noqa +from docx.oxml.shape import ( # noqa CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, @@ -120,36 +136,43 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): CT_ShapeProperties, CT_Transform2D, ) -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) - -from .styles import CT_LatentStyles, CT_LsdException, CT_Style, CT_Styles # noqa -register_element_cls('w:basedOn', CT_String) -register_element_cls('w:latentStyles', CT_LatentStyles) -register_element_cls('w:locked', CT_OnOff) -register_element_cls('w:lsdException', CT_LsdException) -register_element_cls('w:name', CT_String) -register_element_cls('w:next', CT_String) -register_element_cls('w:qFormat', CT_OnOff) -register_element_cls('w:semiHidden', CT_OnOff) -register_element_cls('w:style', CT_Style) -register_element_cls('w:styles', CT_Styles) -register_element_cls('w:uiPriority', CT_DecimalNumber) -register_element_cls('w:unhideWhenUsed', CT_OnOff) - -from .table import ( # noqa + +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) + +from docx.oxml.styles import ( # noqa + CT_LatentStyles, + CT_LsdException, + CT_Style, + CT_Styles, +) + +register_element_cls("w:basedOn", CT_String) +register_element_cls("w:latentStyles", CT_LatentStyles) +register_element_cls("w:locked", CT_OnOff) +register_element_cls("w:lsdException", CT_LsdException) +register_element_cls("w:name", CT_String) +register_element_cls("w:next", CT_String) +register_element_cls("w:qFormat", CT_OnOff) +register_element_cls("w:semiHidden", CT_OnOff) +register_element_cls("w:style", CT_Style) +register_element_cls("w:styles", CT_Styles) +register_element_cls("w:uiPriority", CT_DecimalNumber) +register_element_cls("w:unhideWhenUsed", CT_OnOff) + +from docx.oxml.table import ( # noqa CT_Height, CT_Row, CT_Tbl, @@ -164,24 +187,25 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): CT_VMerge, CT_VerticalJc, ) -register_element_cls('w:bidiVisual', CT_OnOff) -register_element_cls('w:gridCol', CT_TblGridCol) -register_element_cls('w:gridSpan', CT_DecimalNumber) -register_element_cls('w:tbl', CT_Tbl) -register_element_cls('w:tblGrid', CT_TblGrid) -register_element_cls('w:tblLayout', CT_TblLayoutType) -register_element_cls('w:tblPr', CT_TblPr) -register_element_cls('w:tblStyle', CT_String) -register_element_cls('w:tc', CT_Tc) -register_element_cls('w:tcPr', CT_TcPr) -register_element_cls('w:tcW', CT_TblWidth) -register_element_cls('w:tr', CT_Row) -register_element_cls('w:trHeight', CT_Height) -register_element_cls('w:trPr', CT_TrPr) -register_element_cls('w:vAlign', CT_VerticalJc) -register_element_cls('w:vMerge', CT_VMerge) - -from .text.font import ( # noqa + +register_element_cls("w:bidiVisual", CT_OnOff) +register_element_cls("w:gridCol", CT_TblGridCol) +register_element_cls("w:gridSpan", CT_DecimalNumber) +register_element_cls("w:tbl", CT_Tbl) +register_element_cls("w:tblGrid", CT_TblGrid) +register_element_cls("w:tblLayout", CT_TblLayoutType) +register_element_cls("w:tblPr", CT_TblPr) +register_element_cls("w:tblStyle", CT_String) +register_element_cls("w:tc", CT_Tc) +register_element_cls("w:tcPr", CT_TcPr) +register_element_cls("w:tcW", CT_TblWidth) +register_element_cls("w:tr", CT_Row) +register_element_cls("w:trHeight", CT_Height) +register_element_cls("w:trPr", CT_TrPr) +register_element_cls("w:vAlign", CT_VerticalJc) +register_element_cls("w:vMerge", CT_VMerge) + +from docx.oxml.text.font import ( # noqa CT_Color, CT_Fonts, CT_Highlight, @@ -190,39 +214,41 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): CT_Underline, CT_VerticalAlignRun, ) -register_element_cls('w:b', CT_OnOff) -register_element_cls('w:bCs', CT_OnOff) -register_element_cls('w:caps', CT_OnOff) -register_element_cls('w:color', CT_Color) -register_element_cls('w:cs', CT_OnOff) -register_element_cls('w:dstrike', CT_OnOff) -register_element_cls('w:emboss', CT_OnOff) -register_element_cls('w:highlight', CT_Highlight) -register_element_cls('w:i', CT_OnOff) -register_element_cls('w:iCs', CT_OnOff) -register_element_cls('w:imprint', CT_OnOff) -register_element_cls('w:noProof', CT_OnOff) -register_element_cls('w:oMath', CT_OnOff) -register_element_cls('w:outline', CT_OnOff) -register_element_cls('w:rFonts', CT_Fonts) -register_element_cls('w:rPr', CT_RPr) -register_element_cls('w:rStyle', CT_String) -register_element_cls('w:rtl', CT_OnOff) -register_element_cls('w:shadow', CT_OnOff) -register_element_cls('w:smallCaps', CT_OnOff) -register_element_cls('w:snapToGrid', CT_OnOff) -register_element_cls('w:specVanish', CT_OnOff) -register_element_cls('w:strike', CT_OnOff) -register_element_cls('w:sz', CT_HpsMeasure) -register_element_cls('w:u', CT_Underline) -register_element_cls('w:vanish', CT_OnOff) -register_element_cls('w:vertAlign', CT_VerticalAlignRun) -register_element_cls('w:webHidden', CT_OnOff) - -from .text.paragraph import CT_P # noqa -register_element_cls('w:p', CT_P) - -from .text.parfmt import ( # noqa + +register_element_cls("w:b", CT_OnOff) +register_element_cls("w:bCs", CT_OnOff) +register_element_cls("w:caps", CT_OnOff) +register_element_cls("w:color", CT_Color) +register_element_cls("w:cs", CT_OnOff) +register_element_cls("w:dstrike", CT_OnOff) +register_element_cls("w:emboss", CT_OnOff) +register_element_cls("w:highlight", CT_Highlight) +register_element_cls("w:i", CT_OnOff) +register_element_cls("w:iCs", CT_OnOff) +register_element_cls("w:imprint", CT_OnOff) +register_element_cls("w:noProof", CT_OnOff) +register_element_cls("w:oMath", CT_OnOff) +register_element_cls("w:outline", CT_OnOff) +register_element_cls("w:rFonts", CT_Fonts) +register_element_cls("w:rPr", CT_RPr) +register_element_cls("w:rStyle", CT_String) +register_element_cls("w:rtl", CT_OnOff) +register_element_cls("w:shadow", CT_OnOff) +register_element_cls("w:smallCaps", CT_OnOff) +register_element_cls("w:snapToGrid", CT_OnOff) +register_element_cls("w:specVanish", CT_OnOff) +register_element_cls("w:strike", CT_OnOff) +register_element_cls("w:sz", CT_HpsMeasure) +register_element_cls("w:u", CT_Underline) +register_element_cls("w:vanish", CT_OnOff) +register_element_cls("w:vertAlign", CT_VerticalAlignRun) +register_element_cls("w:webHidden", CT_OnOff) + +from docx.oxml.text.paragraph import CT_P # noqa + +register_element_cls("w:p", CT_P) + +from docx.oxml.text.parfmt import ( # noqa CT_Ind, CT_Jc, CT_PPr, @@ -230,19 +256,21 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): CT_TabStop, CT_TabStops, ) -register_element_cls('w:ind', CT_Ind) -register_element_cls('w:jc', CT_Jc) -register_element_cls('w:keepLines', CT_OnOff) -register_element_cls('w:keepNext', CT_OnOff) -register_element_cls('w:pageBreakBefore', CT_OnOff) -register_element_cls('w:pPr', CT_PPr) -register_element_cls('w:pStyle', CT_String) -register_element_cls('w:spacing', CT_Spacing) -register_element_cls('w:tab', CT_TabStop) -register_element_cls('w:tabs', CT_TabStops) -register_element_cls('w:widowControl', CT_OnOff) - -from .text.run import CT_Br, CT_R, CT_Text # noqa -register_element_cls('w:br', CT_Br) -register_element_cls('w:r', CT_R) -register_element_cls('w:t', CT_Text) + +register_element_cls("w:ind", CT_Ind) +register_element_cls("w:jc", CT_Jc) +register_element_cls("w:keepLines", CT_OnOff) +register_element_cls("w:keepNext", CT_OnOff) +register_element_cls("w:pageBreakBefore", CT_OnOff) +register_element_cls("w:pPr", CT_PPr) +register_element_cls("w:pStyle", CT_String) +register_element_cls("w:spacing", CT_Spacing) +register_element_cls("w:tab", CT_TabStop) +register_element_cls("w:tabs", CT_TabStops) +register_element_cls("w:widowControl", CT_OnOff) + +from docx.oxml.text.run import CT_Br, CT_R, CT_Text # noqa + +register_element_cls("w:br", CT_Br) +register_element_cls("w:r", CT_R) +register_element_cls("w:t", CT_Text) diff --git a/docx/oxml/bookmark.py b/docx/oxml/bookmark.py new file mode 100644 index 000000000..253539f68 --- /dev/null +++ b/docx/oxml/bookmark.py @@ -0,0 +1,21 @@ +# encoding: utf-8 + +"""Custom element classes related to bookmarks.""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +from docx.oxml.simpletypes import ST_DecimalNumber, ST_String +from docx.oxml.xmlchemy import BaseOxmlElement, RequiredAttribute + + +class CT_Bookmark(BaseOxmlElement): + """`w:bookmarkStart` element""" + + id = RequiredAttribute("w:id", ST_DecimalNumber) + name = RequiredAttribute("w:name", ST_String) + + +class CT_MarkupRange(BaseOxmlElement): + """`w:bookmarkEnd` element""" + + id = RequiredAttribute("w:id", ST_DecimalNumber) diff --git a/docx/oxml/coreprops.py b/docx/oxml/coreprops.py index ed3dd1001..f9913789a 100644 --- a/docx/oxml/coreprops.py +++ b/docx/oxml/coreprops.py @@ -2,9 +2,7 @@ """Custom element classes for core properties-related XML elements""" -from __future__ import ( - absolute_import, division, print_function, unicode_literals -) +from __future__ import absolute_import, division, print_function, unicode_literals import re @@ -24,25 +22,24 @@ class CT_CoreProperties(BaseOxmlElement): ('') if the element is not present in the XML. String elements are limited in length to 255 unicode characters. """ - category = ZeroOrOne('cp:category', successors=()) - contentStatus = ZeroOrOne('cp:contentStatus', successors=()) - created = ZeroOrOne('dcterms:created', successors=()) - creator = ZeroOrOne('dc:creator', successors=()) - description = ZeroOrOne('dc:description', successors=()) - identifier = ZeroOrOne('dc:identifier', successors=()) - keywords = ZeroOrOne('cp:keywords', successors=()) - language = ZeroOrOne('dc:language', successors=()) - lastModifiedBy = ZeroOrOne('cp:lastModifiedBy', successors=()) - lastPrinted = ZeroOrOne('cp:lastPrinted', successors=()) - modified = ZeroOrOne('dcterms:modified', successors=()) - revision = ZeroOrOne('cp:revision', successors=()) - subject = ZeroOrOne('dc:subject', successors=()) - title = ZeroOrOne('dc:title', successors=()) - version = ZeroOrOne('cp:version', successors=()) - - _coreProperties_tmpl = ( - '\n' % nsdecls('cp', 'dc', 'dcterms') - ) + + category = ZeroOrOne("cp:category", successors=()) + contentStatus = ZeroOrOne("cp:contentStatus", successors=()) + created = ZeroOrOne("dcterms:created", successors=()) + creator = ZeroOrOne("dc:creator", successors=()) + description = ZeroOrOne("dc:description", successors=()) + identifier = ZeroOrOne("dc:identifier", successors=()) + keywords = ZeroOrOne("cp:keywords", successors=()) + language = ZeroOrOne("dc:language", successors=()) + lastModifiedBy = ZeroOrOne("cp:lastModifiedBy", successors=()) + lastPrinted = ZeroOrOne("cp:lastPrinted", successors=()) + modified = ZeroOrOne("dcterms:modified", successors=()) + revision = ZeroOrOne("cp:revision", successors=()) + subject = ZeroOrOne("dc:subject", successors=()) + title = ZeroOrOne("dc:title", successors=()) + version = ZeroOrOne("cp:version", successors=()) + + _coreProperties_tmpl = "\n" % nsdecls("cp", "dc", "dcterms") @classmethod def new(cls): @@ -58,91 +55,91 @@ def author_text(self): """ The text in the `dc:creator` child element. """ - return self._text_of_element('creator') + return self._text_of_element("creator") @author_text.setter def author_text(self, value): - self._set_element_text('creator', value) + self._set_element_text("creator", value) @property def category_text(self): - return self._text_of_element('category') + return self._text_of_element("category") @category_text.setter def category_text(self, value): - self._set_element_text('category', value) + self._set_element_text("category", value) @property def comments_text(self): - return self._text_of_element('description') + return self._text_of_element("description") @comments_text.setter def comments_text(self, value): - self._set_element_text('description', value) + self._set_element_text("description", value) @property def contentStatus_text(self): - return self._text_of_element('contentStatus') + return self._text_of_element("contentStatus") @contentStatus_text.setter def contentStatus_text(self, value): - self._set_element_text('contentStatus', value) + self._set_element_text("contentStatus", value) @property def created_datetime(self): - return self._datetime_of_element('created') + return self._datetime_of_element("created") @created_datetime.setter def created_datetime(self, value): - self._set_element_datetime('created', value) + self._set_element_datetime("created", value) @property def identifier_text(self): - return self._text_of_element('identifier') + return self._text_of_element("identifier") @identifier_text.setter def identifier_text(self, value): - self._set_element_text('identifier', value) + self._set_element_text("identifier", value) @property def keywords_text(self): - return self._text_of_element('keywords') + return self._text_of_element("keywords") @keywords_text.setter def keywords_text(self, value): - self._set_element_text('keywords', value) + self._set_element_text("keywords", value) @property def language_text(self): - return self._text_of_element('language') + return self._text_of_element("language") @language_text.setter def language_text(self, value): - self._set_element_text('language', value) + self._set_element_text("language", value) @property def lastModifiedBy_text(self): - return self._text_of_element('lastModifiedBy') + return self._text_of_element("lastModifiedBy") @lastModifiedBy_text.setter def lastModifiedBy_text(self, value): - self._set_element_text('lastModifiedBy', value) + self._set_element_text("lastModifiedBy", value) @property def lastPrinted_datetime(self): - return self._datetime_of_element('lastPrinted') + return self._datetime_of_element("lastPrinted") @lastPrinted_datetime.setter def lastPrinted_datetime(self, value): - self._set_element_datetime('lastPrinted', value) + self._set_element_datetime("lastPrinted", value) @property def modified_datetime(self): - return self._datetime_of_element('modified') + return self._datetime_of_element("modified") @modified_datetime.setter def modified_datetime(self, value): - self._set_element_datetime('modified', value) + self._set_element_datetime("modified", value) @property def revision_number(self): @@ -176,27 +173,27 @@ def revision_number(self, value): @property def subject_text(self): - return self._text_of_element('subject') + return self._text_of_element("subject") @subject_text.setter def subject_text(self, value): - self._set_element_text('subject', value) + self._set_element_text("subject", value) @property def title_text(self): - return self._text_of_element('title') + return self._text_of_element("title") @title_text.setter def title_text(self, value): - self._set_element_text('title', value) + self._set_element_text("title", value) @property def version_text(self): - return self._text_of_element('version') + return self._text_of_element("version") @version_text.setter def version_text(self, value): - self._set_element_text('version', value) + self._set_element_text("version", value) def _datetime_of_element(self, property_name): element = getattr(self, property_name) @@ -213,7 +210,7 @@ def _get_or_add(self, prop_name): """ Return element returned by 'get_or_add_' method for *prop_name*. """ - get_or_add_method_name = 'get_or_add_%s' % prop_name + get_or_add_method_name = "get_or_add_%s" % prop_name get_or_add_method = getattr(self, get_or_add_method_name) element = get_or_add_method() return element @@ -227,17 +224,15 @@ def _offset_dt(cls, dt, offset_str): """ match = cls._offset_pattern.match(offset_str) if match is None: - raise ValueError( - "'%s' is not a valid offset string" % offset_str - ) + raise ValueError("'%s' is not a valid offset string" % offset_str) sign, hours_str, minutes_str = match.groups() - sign_factor = -1 if sign == '+' else 1 + sign_factor = -1 if sign == "+" else 1 hours = int(hours_str) * sign_factor minutes = int(minutes_str) * sign_factor td = timedelta(hours=hours, minutes=minutes) return dt + td - _offset_pattern = re.compile(r'([+-])(\d\d):(\d\d)') + _offset_pattern = re.compile(r"([+-])(\d\d):(\d\d)") @classmethod def _parse_W3CDTF_to_datetime(cls, w3cdtf_str): @@ -247,12 +242,7 @@ def _parse_W3CDTF_to_datetime(cls, w3cdtf_str): # yyyy-mm-dd e.g. '2003-12-31' # UTC timezone e.g. '2003-12-31T10:14:55Z' # numeric timezone e.g. '2003-12-31T10:14:55-08:00' - templates = ( - '%Y-%m-%dT%H:%M:%S', - '%Y-%m-%d', - '%Y-%m', - '%Y', - ) + templates = ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d", "%Y-%m", "%Y") # strptime isn't smart enough to parse literal timezone offsets like # '-07:30', so we have to do it ourselves parseable_part = w3cdtf_str[:19] @@ -275,21 +265,19 @@ def _set_element_datetime(self, prop_name, value): Set date/time value of child element having *prop_name* to *value*. """ if not isinstance(value, datetime): - tmpl = ( - "property requires object, got %s" - ) + tmpl = "property requires object, got %s" raise ValueError(tmpl % type(value)) element = self._get_or_add(prop_name) - dt_str = value.strftime('%Y-%m-%dT%H:%M:%SZ') + dt_str = value.strftime("%Y-%m-%dT%H:%M:%SZ") element.text = dt_str - if prop_name in ('created', 'modified'): + if prop_name in ("created", "modified"): # These two require an explicit 'xsi:type="dcterms:W3CDTF"' # attribute. The first and last line are a hack required to add # the xsi namespace to the root element rather than each child # element in which it is referenced - self.set(qn('xsi:foo'), 'bar') - element.set(qn('xsi:type'), 'dcterms:W3CDTF') - del self.attrib[qn('xsi:foo')] + self.set(qn("xsi:foo"), "bar") + element.set(qn("xsi:type"), "dcterms:W3CDTF") + del self.attrib[qn("xsi:foo")] def _set_element_text(self, prop_name, value): """Set string value of *name* property to *value*.""" @@ -297,9 +285,7 @@ def _set_element_text(self, prop_name, value): value = str(value) if len(value) > 255: - tmpl = ( - "exceeded 255 char limit for property, got:\n\n'%s'" - ) + tmpl = "exceeded 255 char limit for property, got:\n\n'%s'" raise ValueError(tmpl % value) element = self._get_or_add(prop_name) element.text = value @@ -311,7 +297,7 @@ def _text_of_element(self, property_name): """ element = getattr(self, property_name) if element is None: - return '' + return "" if element.text is None: - return '' + return "" return element.text diff --git a/docx/oxml/document.py b/docx/oxml/document.py index 4211b8ed1..bee856b49 100644 --- a/docx/oxml/document.py +++ b/docx/oxml/document.py @@ -1,9 +1,6 @@ # encoding: utf-8 -""" -Custom element classes that correspond to the document part, e.g. -. -""" +"""Custom element classes that correspond to the document part, e.g. .""" from .xmlchemy import BaseOxmlElement, ZeroOrOne, ZeroOrMore @@ -12,7 +9,8 @@ class CT_Document(BaseOxmlElement): """ ```` element, the root element of a document.xml file. """ - body = ZeroOrOne('w:body') + + body = ZeroOrOne("w:body") @property def sectPr_lst(self): @@ -20,17 +18,40 @@ def sectPr_lst(self): Return a list containing a reference to each ```` element in the document, in the order encountered. """ - return self.xpath('.//w:sectPr') + return self.xpath(".//w:sectPr") class CT_Body(BaseOxmlElement): - """ - ````, the container element for the main document story in - ``document.xml``. - """ - p = ZeroOrMore('w:p', successors=('w:sectPr',)) - tbl = ZeroOrMore('w:tbl', successors=('w:sectPr',)) - sectPr = ZeroOrOne('w:sectPr', successors=()) + """`w:body`, the container element for the main document story in `document.xml`""" + + p = ZeroOrMore("w:p", successors=("w:sectPr",)) + tbl = ZeroOrMore("w:tbl", successors=("w:sectPr",)) + bookmarkStart = ZeroOrMore("w:bookmarkStart", successors=("w:sectPr",)) + bookmarkEnd = ZeroOrMore("w:bookmarkEnd", successors=("w:sectPr",)) + sectPr = ZeroOrOne("w:sectPr", successors=()) + + def add_bookmarkEnd(self, bookmark_id): + """Return `w:bookmarkEnd` element added at end of document. + + The newly added `w:bookmarkEnd` element is linked to it's `w:bookmarkStart` + counterpart by `bookmark_id`. It is the caller's responsibility to determine + `bookmark_id` matches that of the intended `bookmarkStart` element. + """ + bookmarkEnd = self._add_bookmarkEnd() + bookmarkEnd.id = bookmark_id + return bookmarkEnd + + def add_bookmarkStart(self, name, bookmark_id): + """Return `w:bookmarkStart` element added at end of document. + + The newly added `w:bookmarkStart` element is identified by both `name` and + `bookmark_id`. It is the caller's responsibility to determine that both `name` + and `bookmark_id` are unique, document-wide. + """ + bookmarkStart = self._add_bookmarkStart() + bookmarkStart.name = name + bookmarkStart.id = bookmark_id + return bookmarkStart def add_section_break(self): """Return `w:sectPr` element for new section added at end of document. diff --git a/docx/oxml/endnotes.py b/docx/oxml/endnotes.py new file mode 100644 index 000000000..2f3605f2d --- /dev/null +++ b/docx/oxml/endnotes.py @@ -0,0 +1,11 @@ +# encoding: utf-8 + +"""Custom element classes related to end-notes""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +from docx.oxml.xmlchemy import BaseOxmlElement + + +class CT_Endnotes(BaseOxmlElement): + """`w:endnotes` element""" diff --git a/docx/oxml/footnotes.py b/docx/oxml/footnotes.py new file mode 100644 index 000000000..d9a6d072c --- /dev/null +++ b/docx/oxml/footnotes.py @@ -0,0 +1,11 @@ +# encoding: utf-8 + +"""Custom element classes related to footnotes""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +from docx.oxml.xmlchemy import BaseOxmlElement + + +class CT_Footnotes(BaseOxmlElement): + """`w:footnotes` element""" diff --git a/docx/oxml/numbering.py b/docx/oxml/numbering.py index aeedfa9a0..1328bd0f2 100644 --- a/docx/oxml/numbering.py +++ b/docx/oxml/numbering.py @@ -125,7 +125,7 @@ def _next_numId(self): """ numId_strs = self.xpath('./w:num/@w:numId') num_ids = [int(numId_str) for numId_str in numId_strs] - for num in range(1, len(num_ids)+2): + for num in range(1, len(num_ids) + 2): if num not in num_ids: break return num diff --git a/docx/oxml/section.py b/docx/oxml/section.py index fc953e74d..b572b5e44 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -20,38 +20,65 @@ class CT_HdrFtr(BaseOxmlElement): """`w:hdr` and `w:ftr`, the root element for header and footer part respectively""" - p = ZeroOrMore('w:p', successors=()) - tbl = ZeroOrMore('w:tbl', successors=()) + p = ZeroOrMore("w:p", successors=()) + tbl = ZeroOrMore("w:tbl", successors=()) + bookmarkStart = ZeroOrMore("w:bookmarkStart", successors=()) + bookmarkEnd = ZeroOrMore("w:bookmarkEnd", successors=()) + + def add_bookmarkEnd(self, bookmark_id): + """Return `w:bookmarkEnd` element added at end of this header or footer. + + The newly added `w:bookmarkEnd` element is linked to it's `w:bookmarkStart` + counterpart by `bookmark_id`. It is the caller's responsibility to determine + `bookmark_id` matches that of the intended `bookmarkStart` element. + """ + bookmarkEnd = self._add_bookmarkEnd() + bookmarkEnd.id = bookmark_id + return bookmarkEnd + + def add_bookmarkStart(self, name, bookmark_id): + """Return `w:bookmarkStart` element added at the end of this header or footer. + + The newly added `w:bookmarkStart` element is identified by both `name` and + `bookmark_id`. It is the caller's responsibility to determine that both `name` + and `bookmark_id` are unique, document-wide. + """ + bookmarkStart = self._add_bookmarkStart() + bookmarkStart.name = name + bookmarkStart.id = bookmark_id + return bookmarkStart class CT_HdrFtrRef(BaseOxmlElement): """`w:headerReference` and `w:footerReference` elements""" - type_ = RequiredAttribute('w:type', WD_HEADER_FOOTER) - rId = RequiredAttribute('r:id', XsdString) + type_ = RequiredAttribute("w:type", WD_HEADER_FOOTER) + rId = RequiredAttribute("r:id", XsdString) class CT_PageMar(BaseOxmlElement): """ ```` element, defining page margins. """ - top = OptionalAttribute('w:top', ST_SignedTwipsMeasure) - right = OptionalAttribute('w:right', ST_TwipsMeasure) - bottom = OptionalAttribute('w:bottom', ST_SignedTwipsMeasure) - left = OptionalAttribute('w:left', ST_TwipsMeasure) - header = OptionalAttribute('w:header', ST_TwipsMeasure) - footer = OptionalAttribute('w:footer', ST_TwipsMeasure) - gutter = OptionalAttribute('w:gutter', ST_TwipsMeasure) + + top = OptionalAttribute("w:top", ST_SignedTwipsMeasure) + right = OptionalAttribute("w:right", ST_TwipsMeasure) + bottom = OptionalAttribute("w:bottom", ST_SignedTwipsMeasure) + left = OptionalAttribute("w:left", ST_TwipsMeasure) + header = OptionalAttribute("w:header", ST_TwipsMeasure) + footer = OptionalAttribute("w:footer", ST_TwipsMeasure) + gutter = OptionalAttribute("w:gutter", ST_TwipsMeasure) class CT_PageSz(BaseOxmlElement): """ ```` element, defining page dimensions and orientation. """ - w = OptionalAttribute('w:w', ST_TwipsMeasure) - h = OptionalAttribute('w:h', ST_TwipsMeasure) + + w = OptionalAttribute("w:w", ST_TwipsMeasure) + h = OptionalAttribute("w:h", ST_TwipsMeasure) orient = OptionalAttribute( - 'w:orient', WD_ORIENTATION, default=WD_ORIENTATION.PORTRAIT + "w:orient", WD_ORIENTATION, default=WD_ORIENTATION.PORTRAIT ) @@ -59,10 +86,26 @@ class CT_SectPr(BaseOxmlElement): """`w:sectPr` element, the container element for section properties""" _tag_seq = ( - 'w:footnotePr', 'w:endnotePr', 'w:type', 'w:pgSz', 'w:pgMar', 'w:paperSrc', - 'w:pgBorders', 'w:lnNumType', 'w:pgNumType', 'w:cols', 'w:formProt', 'w:vAlign', - 'w:noEndnote', 'w:titlePg', 'w:textDirection', 'w:bidi', 'w:rtlGutter', - 'w:docGrid', 'w:printerSettings', 'w:sectPrChange', + "w:footnotePr", + "w:endnotePr", + "w:type", + "w:pgSz", + "w:pgMar", + "w:paperSrc", + "w:pgBorders", + "w:lnNumType", + "w:pgNumType", + "w:cols", + "w:formProt", + "w:vAlign", + "w:noEndnote", + "w:titlePg", + "w:textDirection", + "w:bidi", + "w:rtlGutter", + "w:docGrid", + "w:printerSettings", + "w:sectPrChange", ) headerReference = ZeroOrMore("w:headerReference", successors=_tag_seq) footerReference = ZeroOrMore("w:footerReference", successors=_tag_seq) @@ -348,4 +391,5 @@ class CT_SectType(BaseOxmlElement): """ ```` element, defining the section start type. """ - val = OptionalAttribute('w:val', WD_SECTION_START) + + val = OptionalAttribute("w:val", WD_SECTION_START) diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 400a23700..085cc6fd0 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -280,7 +280,7 @@ class ST_HpsMeasure(XsdUnsignedLong): def convert_from_xml(cls, str_value): if 'm' in str_value or 'n' in str_value or 'p' in str_value: return ST_UniversalMeasure.convert_from_xml(str_value) - return Pt(int(str_value)/2.0) + return Pt(int(str_value) / 2.0) @classmethod def convert_to_xml(cls, value): diff --git a/docx/oxml/table.py b/docx/oxml/table.py index e55bf9126..7598ab9ee 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -2,9 +2,7 @@ """Custom element classes for tables""" -from __future__ import ( - absolute_import, division, print_function, unicode_literals -) +from __future__ import absolute_import, division, print_function, unicode_literals from . import parse_xml from ..enum.table import WD_CELL_VERTICAL_ALIGNMENT, WD_ROW_HEIGHT_RULE @@ -12,11 +10,20 @@ from .ns import nsdecls, qn from ..shared import Emu, Twips from .simpletypes import ( - ST_Merge, ST_TblLayoutType, ST_TblWidth, ST_TwipsMeasure, XsdInt + ST_Merge, + ST_TblLayoutType, + ST_TblWidth, + ST_TwipsMeasure, + XsdInt, ) from .xmlchemy import ( - BaseOxmlElement, OneAndOnlyOne, OneOrMore, OptionalAttribute, - RequiredAttribute, ZeroOrOne, ZeroOrMore + BaseOxmlElement, + OneAndOnlyOne, + OneOrMore, + OptionalAttribute, + RequiredAttribute, + ZeroOrOne, + ZeroOrMore, ) @@ -24,17 +31,19 @@ class CT_Height(BaseOxmlElement): """ Used for ```` to specify a row height and row height rule. """ - val = OptionalAttribute('w:val', ST_TwipsMeasure) - hRule = OptionalAttribute('w:hRule', WD_ROW_HEIGHT_RULE) + + val = OptionalAttribute("w:val", ST_TwipsMeasure) + hRule = OptionalAttribute("w:hRule", WD_ROW_HEIGHT_RULE) class CT_Row(BaseOxmlElement): """ ```` element """ - tblPrEx = ZeroOrOne('w:tblPrEx') # custom inserter below - trPr = ZeroOrOne('w:trPr') # custom inserter below - tc = ZeroOrMore('w:tc') + + tblPrEx = ZeroOrOne("w:tblPrEx") # custom inserter below + trPr = ZeroOrOne("w:trPr") # custom inserter below + tc = ZeroOrMore("w:tc") def tc_at_grid_col(self, idx): """ @@ -47,8 +56,8 @@ def tc_at_grid_col(self, idx): return tc grid_col += tc.grid_span if grid_col > idx: - raise ValueError('no cell on grid column %d' % idx) - raise ValueError('index out of bounds') + raise ValueError("no cell on grid column %d" % idx) + raise ValueError("index out of bounds") @property def tr_idx(self): @@ -108,9 +117,10 @@ class CT_Tbl(BaseOxmlElement): """ ```` element """ - tblPr = OneAndOnlyOne('w:tblPr') - tblGrid = OneAndOnlyOne('w:tblGrid') - tr = ZeroOrMore('w:tr') + + tblPr = OneAndOnlyOne("w:tblPr") + tblGrid = OneAndOnlyOne("w:tblGrid") + tr = ZeroOrMore("w:tr") @property def bidiVisual_val(self): @@ -182,54 +192,52 @@ def tblStyle_val(self, styleId): @classmethod def _tbl_xml(cls, rows, cols, width): - col_width = Emu(width/cols) if cols > 0 else Emu(0) + col_width = Emu(width / cols) if cols > 0 else Emu(0) return ( - '\n' - ' \n' + "\n" + " \n" ' \n' ' \n' - ' \n' - '%s' # tblGrid - '%s' # trs - '\n' + " \n" + "%s" # tblGrid + "%s" # trs + "\n" ) % ( - nsdecls('w'), + nsdecls("w"), cls._tblGrid_xml(cols, col_width), - cls._trs_xml(rows, cols, col_width) + cls._trs_xml(rows, cols, col_width), ) @classmethod def _tblGrid_xml(cls, col_count, col_width): - xml = ' \n' + xml = " \n" for i in range(col_count): xml += ' \n' % col_width.twips - xml += ' \n' + xml += " \n" return xml @classmethod def _trs_xml(cls, row_count, col_count, col_width): - xml = '' + xml = "" for i in range(row_count): - xml += ( - ' \n' - '%s' - ' \n' - ) % cls._tcs_xml(col_count, col_width) + xml += (" \n" "%s" " \n") % cls._tcs_xml( + col_count, col_width + ) return xml @classmethod def _tcs_xml(cls, col_count, col_width): - xml = '' + xml = "" for i in range(col_count): xml += ( - ' \n' - ' \n' + " \n" + " \n" ' \n' - ' \n' - ' \n' - ' \n' + " \n" + " \n" + " \n" ) % col_width.twips return xml @@ -239,7 +247,8 @@ class CT_TblGrid(BaseOxmlElement): ```` element, child of ````, holds ```` elements that define column count, width, etc. """ - gridCol = ZeroOrMore('w:gridCol', successors=('w:tblGridChange',)) + + gridCol = ZeroOrMore("w:gridCol", successors=("w:tblGridChange",)) class CT_TblGridCol(BaseOxmlElement): @@ -247,7 +256,8 @@ class CT_TblGridCol(BaseOxmlElement): ```` element, child of ````, defines a table column. """ - w = OptionalAttribute('w:w', ST_TwipsMeasure) + + w = OptionalAttribute("w:w", ST_TwipsMeasure) @property def gridCol_idx(self): @@ -263,7 +273,8 @@ class CT_TblLayoutType(BaseOxmlElement): ```` element, specifying whether column widths are fixed or can be automatically adjusted based on content. """ - type = OptionalAttribute('w:type', ST_TblLayoutType) + + type = OptionalAttribute("w:type", ST_TblLayoutType) class CT_TblPr(BaseOxmlElement): @@ -271,17 +282,31 @@ class CT_TblPr(BaseOxmlElement): ```` element, child of ````, holds child elements that define table properties such as style and borders. """ + _tag_seq = ( - 'w:tblStyle', 'w:tblpPr', 'w:tblOverlap', 'w:bidiVisual', - 'w:tblStyleRowBandSize', 'w:tblStyleColBandSize', 'w:tblW', 'w:jc', - 'w:tblCellSpacing', 'w:tblInd', 'w:tblBorders', 'w:shd', - 'w:tblLayout', 'w:tblCellMar', 'w:tblLook', 'w:tblCaption', - 'w:tblDescription', 'w:tblPrChange' + "w:tblStyle", + "w:tblpPr", + "w:tblOverlap", + "w:bidiVisual", + "w:tblStyleRowBandSize", + "w:tblStyleColBandSize", + "w:tblW", + "w:jc", + "w:tblCellSpacing", + "w:tblInd", + "w:tblBorders", + "w:shd", + "w:tblLayout", + "w:tblCellMar", + "w:tblLook", + "w:tblCaption", + "w:tblDescription", + "w:tblPrChange", ) - tblStyle = ZeroOrOne('w:tblStyle', successors=_tag_seq[1:]) - bidiVisual = ZeroOrOne('w:bidiVisual', successors=_tag_seq[4:]) - jc = ZeroOrOne('w:jc', successors=_tag_seq[8:]) - tblLayout = ZeroOrOne('w:tblLayout', successors=_tag_seq[13:]) + tblStyle = ZeroOrOne("w:tblStyle", successors=_tag_seq[1:]) + bidiVisual = ZeroOrOne("w:bidiVisual", successors=_tag_seq[4:]) + jc = ZeroOrOne("w:jc", successors=_tag_seq[8:]) + tblLayout = ZeroOrOne("w:tblLayout", successors=_tag_seq[13:]) del _tag_seq @property @@ -313,12 +338,12 @@ def autofit(self): tblLayout = self.tblLayout if tblLayout is None: return True - return False if tblLayout.type == 'fixed' else True + return False if tblLayout.type == "fixed" else True @autofit.setter def autofit(self, value): tblLayout = self.get_or_add_tblLayout() - tblLayout.type = 'autofit' if value else 'fixed' + tblLayout.type = "autofit" if value else "fixed" @property def style(self): @@ -344,11 +369,12 @@ class CT_TblWidth(BaseOxmlElement): Used for ```` and ```` elements and many others, to specify a table-related width. """ + # the type for `w` attr is actually ST_MeasurementOrPercent, but using # XsdInt for now because only dxa (twips) values are being used. It's not # entirely clear what the semantics are for other values like -01.4mm - w = RequiredAttribute('w:w', XsdInt) - type = RequiredAttribute('w:type', ST_TblWidth) + w = RequiredAttribute("w:w", XsdInt) + type = RequiredAttribute("w:type", ST_TblWidth) @property def width(self): @@ -356,22 +382,47 @@ def width(self): Return the EMU length value represented by the combined ``w:w`` and ``w:type`` attributes. """ - if self.type != 'dxa': + if self.type != "dxa": return None return Twips(self.w) @width.setter def width(self, value): - self.type = 'dxa' + self.type = "dxa" self.w = Emu(value).twips class CT_Tc(BaseOxmlElement): """`w:tc` table cell element""" - tcPr = ZeroOrOne('w:tcPr') # bunches of successors, overriding insert - p = OneOrMore('w:p') - tbl = OneOrMore('w:tbl') + tcPr = ZeroOrOne("w:tcPr") # bunches of successors, overriding insert + p = OneOrMore("w:p") + tbl = OneOrMore("w:tbl") + bookmarkStart = ZeroOrMore("w:bookmarkStart", successors=()) + bookmarkEnd = ZeroOrMore("w:bookmarkEnd", successors=()) + + def add_bookmarkEnd(self, bookmark_id): + """Return `w:bookmarkEnd` element added at end of this cell. + + The newly added `w:bookmarkEnd` element is linked to it's `w:bookmarkStart` + counterpart by `bookmark_id`. It is the caller's responsibility to determine + `bookmark_id` matches that of the intended `bookmarkStart` element. + """ + bookmarkEnd = self._add_bookmarkEnd() + bookmarkEnd.id = bookmark_id + return bookmarkEnd + + def add_bookmarkStart(self, name, bookmark_id): + """Return `w:bookmarkStart` element added at the end of this cell. + + The newly added `w:bookmarkStart` element is identified by both `name` and + `bookmark_id`. It is the caller's responsibility to determine that both `name` + and `bookmark_id` are unique, document-wide. + """ + bookmarkStart = self._add_bookmarkStart() + bookmarkStart.name = name + bookmarkStart.id = bookmark_id + return bookmarkStart @property def bottom(self): @@ -422,7 +473,7 @@ def iter_block_items(self): Generate a reference to each of the block-level content elements in this cell, in the order they appear. """ - block_item_tags = (qn('w:p'), qn('w:tbl'), qn('w:sdt')) + block_item_tags = (qn("w:p"), qn("w:tbl"), qn("w:sdt")) for child in self: if child.tag in block_item_tags: yield child @@ -451,11 +502,7 @@ def new(cls): Return a new ```` element, containing an empty paragraph as the required EG_BlockLevelElt. """ - return parse_xml( - '\n' - ' \n' - '' % nsdecls('w') - ) + return parse_xml("\n" " \n" "" % nsdecls("w")) @property def right(self): @@ -532,6 +579,7 @@ def _grow_to(self, width, height, top_tc=None): horizontal spans and creating continuation cells to form vertical spans. """ + def vMerge_val(top_tc): if top_tc is not self: return ST_Merge.CONTINUE @@ -542,7 +590,7 @@ def vMerge_val(top_tc): top_tc = self if top_tc is None else top_tc self._span_to_width(width, top_tc, vMerge_val(top_tc)) if height > 1: - self._tc_below._grow_to(width, height-1, top_tc) + self._tc_below._grow_to(width, height - 1, top_tc) def _insert_tcPr(self, tcPr): """ @@ -591,7 +639,7 @@ def _next_tc(self): The `w:tc` element immediately following this one in this row, or |None| if this is the last `w:tc` element in the row. """ - following_tcs = self.xpath('./following-sibling::w:tc') + following_tcs = self.xpath("./following-sibling::w:tc") return following_tcs[0] if following_tcs else None def _remove(self): @@ -607,7 +655,7 @@ def _remove_trailing_empty_p(self): """ block_items = list(self.iter_block_items()) last_content_elm = block_items[-1] - if last_content_elm.tag != qn('w:p'): + if last_content_elm.tag != qn("w:p"): return p = last_content_elm if len(p.r_lst) > 0: @@ -620,20 +668,21 @@ def _span_dimensions(self, other_tc): the merged cell formed by using this tc and *other_tc* as opposite corner extents. """ + def raise_on_inverted_L(a, b): if a.top == b.top and a.bottom != b.bottom: - raise InvalidSpanError('requested span not rectangular') + raise InvalidSpanError("requested span not rectangular") if a.left == b.left and a.right != b.right: - raise InvalidSpanError('requested span not rectangular') + raise InvalidSpanError("requested span not rectangular") def raise_on_tee_shaped(a, b): top_most, other = (a, b) if a.top < b.top else (b, a) if top_most.top < other.top and top_most.bottom > other.bottom: - raise InvalidSpanError('requested span not rectangular') + raise InvalidSpanError("requested span not rectangular") left_most, other = (a, b) if a.left < b.left else (b, a) if left_most.left < other.left and left_most.right > other.right: - raise InvalidSpanError('requested span not rectangular') + raise InvalidSpanError("requested span not rectangular") raise_on_inverted_L(self, other_tc) raise_on_tee_shaped(self, other_tc) @@ -671,11 +720,12 @@ def _swallow_next_tc(self, grid_width, top_tc): |InvalidSpanError| if the width of the resulting cell is greater than *grid_width* or if there is no next `` element in the row. """ + def raise_on_invalid_swallow(next_tc): if next_tc is None: - raise InvalidSpanError('not enough grid columns') + raise InvalidSpanError("not enough grid columns") if self.grid_span + next_tc.grid_span > grid_width: - raise InvalidSpanError('span is not rectangular') + raise InvalidSpanError("span is not rectangular") next_tc = self._next_tc raise_on_invalid_swallow(next_tc) @@ -689,7 +739,7 @@ def _tbl(self): """ The tbl element this tc element appears in. """ - return self.xpath('./ancestor::w:tbl[position()=1]')[0] + return self.xpath("./ancestor::w:tbl[position()=1]")[0] @property def _tc_above(self): @@ -713,7 +763,7 @@ def _tr(self): """ The tr element this tc element appears in. """ - return self.xpath('./ancestor::w:tr[position()=1]')[0] + return self.xpath("./ancestor::w:tr[position()=1]")[0] @property def _tr_above(self): @@ -724,8 +774,8 @@ def _tr_above(self): tr_lst = self._tbl.tr_lst tr_idx = tr_lst.index(self._tr) if tr_idx == 0: - raise ValueError('no tr above topmost tr') - return tr_lst[tr_idx-1] + raise ValueError("no tr above topmost tr") + return tr_lst[tr_idx - 1] @property def _tr_below(self): @@ -736,7 +786,7 @@ def _tr_below(self): tr_lst = self._tbl.tr_lst tr_idx = tr_lst.index(self._tr) try: - return tr_lst[tr_idx+1] + return tr_lst[tr_idx + 1] except IndexError: return None @@ -752,16 +802,31 @@ class CT_TcPr(BaseOxmlElement): """ ```` element, defining table cell properties """ + _tag_seq = ( - 'w:cnfStyle', 'w:tcW', 'w:gridSpan', 'w:hMerge', 'w:vMerge', - 'w:tcBorders', 'w:shd', 'w:noWrap', 'w:tcMar', 'w:textDirection', - 'w:tcFitText', 'w:vAlign', 'w:hideMark', 'w:headers', 'w:cellIns', - 'w:cellDel', 'w:cellMerge', 'w:tcPrChange' + "w:cnfStyle", + "w:tcW", + "w:gridSpan", + "w:hMerge", + "w:vMerge", + "w:tcBorders", + "w:shd", + "w:noWrap", + "w:tcMar", + "w:textDirection", + "w:tcFitText", + "w:vAlign", + "w:hideMark", + "w:headers", + "w:cellIns", + "w:cellDel", + "w:cellMerge", + "w:tcPrChange", ) - tcW = ZeroOrOne('w:tcW', successors=_tag_seq[2:]) - gridSpan = ZeroOrOne('w:gridSpan', successors=_tag_seq[3:]) - vMerge = ZeroOrOne('w:vMerge', successors=_tag_seq[5:]) - vAlign = ZeroOrOne('w:vAlign', successors=_tag_seq[12:]) + tcW = ZeroOrOne("w:tcW", successors=_tag_seq[2:]) + gridSpan = ZeroOrOne("w:gridSpan", successors=_tag_seq[3:]) + vMerge = ZeroOrOne("w:vMerge", successors=_tag_seq[5:]) + vAlign = ZeroOrOne("w:vAlign", successors=_tag_seq[12:]) del _tag_seq @property @@ -838,13 +903,25 @@ class CT_TrPr(BaseOxmlElement): """ ```` element, defining table row properties """ + _tag_seq = ( - 'w:cnfStyle', 'w:divId', 'w:gridBefore', 'w:gridAfter', 'w:wBefore', - 'w:wAfter', 'w:cantSplit', 'w:trHeight', 'w:tblHeader', - 'w:tblCellSpacing', 'w:jc', 'w:hidden', 'w:ins', 'w:del', - 'w:trPrChange' + "w:cnfStyle", + "w:divId", + "w:gridBefore", + "w:gridAfter", + "w:wBefore", + "w:wAfter", + "w:cantSplit", + "w:trHeight", + "w:tblHeader", + "w:tblCellSpacing", + "w:jc", + "w:hidden", + "w:ins", + "w:del", + "w:trPrChange", ) - trHeight = ZeroOrOne('w:trHeight', successors=_tag_seq[8:]) + trHeight = ZeroOrOne("w:trHeight", successors=_tag_seq[8:]) del _tag_seq @property @@ -884,11 +961,13 @@ def trHeight_val(self, value): class CT_VerticalJc(BaseOxmlElement): """`w:vAlign` element, specifying vertical alignment of cell.""" - val = RequiredAttribute('w:val', WD_CELL_VERTICAL_ALIGNMENT) + + val = RequiredAttribute("w:val", WD_CELL_VERTICAL_ALIGNMENT) class CT_VMerge(BaseOxmlElement): """ ```` element, specifying vertical merging behavior of a cell. """ - val = OptionalAttribute('w:val', ST_Merge, default=ST_Merge.CONTINUE) + + val = OptionalAttribute("w:val", ST_Merge, default=ST_Merge.CONTINUE) diff --git a/docx/oxml/xmlchemy.py b/docx/oxml/xmlchemy.py index 46dbf462b..0fff364dc 100644 --- a/docx/oxml/xmlchemy.py +++ b/docx/oxml/xmlchemy.py @@ -23,7 +23,7 @@ def serialize_for_reading(element): Serialize *element* to human-readable XML suitable for tests. No XML declaration. """ - xml = etree.tostring(element, encoding='unicode', pretty_print=True) + xml = etree.tostring(element, encoding="unicode", pretty_print=True) return XmlString(xml) @@ -39,7 +39,7 @@ class XmlString(Unicode): # front attrs | text # close - _xml_elm_line_patt = re.compile(r'( *)([^<]*)?$') + _xml_elm_line_patt = re.compile(r"( *)([^<]*)?$") def __eq__(self, other): lines = self.splitlines() @@ -95,10 +95,16 @@ class MetaOxmlElement(type): """ Metaclass for BaseOxmlElement """ + def __init__(cls, clsname, bases, clsdict): dispatchable = ( - OneAndOnlyOne, OneOrMore, OptionalAttribute, RequiredAttribute, - ZeroOrMore, ZeroOrOne, ZeroOrOneChoice + OneAndOnlyOne, + OneOrMore, + OptionalAttribute, + RequiredAttribute, + ZeroOrMore, + ZeroOrOne, + ZeroOrOneChoice, ) for key, value in clsdict.items(): if isinstance(value, dispatchable): @@ -110,6 +116,7 @@ class BaseAttribute(object): Base class for OptionalAttribute and RequiredAttribute, providing common methods. """ + def __init__(self, attr_name, simple_type): super(BaseAttribute, self).__init__() self._attr_name = attr_name @@ -136,7 +143,7 @@ def _add_attr_property(self): @property def _clark_name(self): - if ':' in self._attr_name: + if ":" in self._attr_name: return qn(self._attr_name) return self._attr_name @@ -147,6 +154,7 @@ class OptionalAttribute(BaseAttribute): attribute returns a default value when not present for reading. When assigned |None|, the attribute is removed. """ + def __init__(self, attr_name, simple_type, default=None): super(OptionalAttribute, self).__init__(attr_name, simple_type) self._default = default @@ -157,11 +165,13 @@ def _getter(self): Return a function object suitable for the "get" side of the attribute property descriptor. """ + def get_attr_value(obj): attr_str_value = obj.get(self._clark_name) if attr_str_value is None: return self._default return self._simple_type.from_xml(attr_str_value) + get_attr_value.__doc__ = self._docstring return get_attr_value @@ -172,10 +182,10 @@ def _docstring(self): for this attribute. """ return ( - '%s type-converted value of ``%s`` attribute, or |None| (or spec' - 'ified default value) if not present. Assigning the default valu' - 'e causes the attribute to be removed from the element.' % - (self._simple_type.__name__, self._attr_name) + "%s type-converted value of ``%s`` attribute, or |None| (or spec" + "ified default value) if not present. Assigning the default valu" + "e causes the attribute to be removed from the element." + % (self._simple_type.__name__, self._attr_name) ) @property @@ -184,6 +194,7 @@ def _setter(self): Return a function object suitable for the "set" side of the attribute property descriptor. """ + def set_attr_value(obj, value): if value is None or value == self._default: if self._clark_name in obj.attrib: @@ -191,6 +202,7 @@ def set_attr_value(obj, value): return str_value = self._simple_type.to_xml(value) obj.set(self._clark_name, str_value) + return set_attr_value @@ -203,20 +215,23 @@ class RequiredAttribute(BaseAttribute): |None| is assigned. Assigning |None| raises |TypeError| or |ValueError|, depending on the simple type of the attribute. """ + @property def _getter(self): """ Return a function object suitable for the "get" side of the attribute property descriptor. """ + def get_attr_value(obj): attr_str_value = obj.get(self._clark_name) if attr_str_value is None: raise InvalidXmlError( - "required '%s' attribute not present on element %s" % - (self._attr_name, obj.tag) + "required '%s' attribute not present on element %s" + % (self._attr_name, obj.tag) ) return self._simple_type.from_xml(attr_str_value) + get_attr_value.__doc__ = self._docstring return get_attr_value @@ -226,9 +241,9 @@ def _docstring(self): Return the string to use as the ``__doc__`` attribute of the property for this attribute. """ - return ( - '%s type-converted value of ``%s`` attribute.' % - (self._simple_type.__name__, self._attr_name) + return "%s type-converted value of ``%s`` attribute." % ( + self._simple_type.__name__, + self._attr_name, ) @property @@ -237,9 +252,11 @@ def _setter(self): Return a function object suitable for the "set" side of the attribute property descriptor. """ + def set_attr_value(obj, value): str_value = self._simple_type.to_xml(value) obj.set(self._clark_name, str_value) + return set_attr_value @@ -248,6 +265,7 @@ class _BaseChildElement(object): Base class for the child element classes corresponding to varying cardinalities, such as ZeroOrOne and ZeroOrMore. """ + def __init__(self, nsptagname, successors=()): super(_BaseChildElement, self).__init__() self._nsptagname = nsptagname @@ -266,6 +284,7 @@ def _add_adder(self): Add an ``_add_x()`` method to the element class for this child element. """ + def _add_child(obj, **attrs): new_method = getattr(obj, self._new_method_name) child = new_method() @@ -276,8 +295,8 @@ def _add_child(obj, **attrs): return child _add_child.__doc__ = ( - 'Add a new ``<%s>`` child element unconditionally, inserted in t' - 'he correct sequence.' % self._nsptagname + "Add a new ``<%s>`` child element unconditionally, inserted in t" + "he correct sequence." % self._nsptagname ) self._add_to_class(self._add_method_name, _add_child) @@ -289,7 +308,7 @@ def _add_creator(self): creator = self._creator creator.__doc__ = ( 'Return a "loose", newly created ``<%s>`` element having no attri' - 'butes, text, or children.' % self._nsptagname + "butes, text, or children." % self._nsptagname ) self._add_to_class(self._new_method_name, creator) @@ -307,13 +326,14 @@ def _add_inserter(self): Add an ``_insert_x()`` method to the element class for this child element. """ + def _insert_child(obj, child): obj.insert_element_before(child, *self._successors) return child _insert_child.__doc__ = ( - 'Return the passed ``<%s>`` element after inserting it as a chil' - 'd in the correct sequence.' % self._nsptagname + "Return the passed ``<%s>`` element after inserting it as a chil" + "d in the correct sequence." % self._nsptagname ) self._add_to_class(self._insert_method_name, _insert_child) @@ -322,26 +342,27 @@ def _add_list_getter(self): Add a read-only ``{prop_name}_lst`` property to the element class to retrieve a list of child elements matching this type. """ - prop_name = '%s_lst' % self._prop_name + prop_name = "%s_lst" % self._prop_name property_ = property(self._list_getter, None, None) setattr(self._element_cls, prop_name, property_) @lazyproperty def _add_method_name(self): - return '_add_%s' % self._prop_name + return "_add_%s" % self._prop_name def _add_public_adder(self): """ Add a public ``add_x()`` method to the parent element class. """ + def add_child(obj): private_add_method = getattr(obj, self._add_method_name) child = private_add_method() return child add_child.__doc__ = ( - 'Add a new ``<%s>`` child element unconditionally, inserted in t' - 'he correct sequence.' % self._nsptagname + "Add a new ``<%s>`` child element unconditionally, inserted in t" + "he correct sequence." % self._nsptagname ) self._add_to_class(self._public_add_method_name, add_child) @@ -360,8 +381,10 @@ def _creator(self): Return a function object that creates a new, empty element of the right type, having no attributes. """ + def new_child_element(obj): return OxmlElement(self._nsptagname) + return new_child_element @property @@ -371,17 +394,18 @@ def _getter(self): descriptor. This default getter returns the child element with matching tag name or |None| if not present. """ + def get_child_element(obj): return obj.find(qn(self._nsptagname)) + get_child_element.__doc__ = ( - '``<%s>`` child element or |None| if not present.' - % self._nsptagname + "``<%s>`` child element or |None| if not present." % self._nsptagname ) return get_child_element @lazyproperty def _insert_method_name(self): - return '_insert_%s' % self._prop_name + return "_insert_%s" % self._prop_name @property def _list_getter(self): @@ -389,11 +413,13 @@ def _list_getter(self): Return a function object suitable for the "get" side of a list property descriptor. """ + def get_child_element_list(obj): return obj.findall(qn(self._nsptagname)) + get_child_element_list.__doc__ = ( - 'A list containing each of the ``<%s>`` child elements, in the o' - 'rder they appear.' % self._nsptagname + "A list containing each of the ``<%s>`` child elements, in the o" + "rder they appear." % self._nsptagname ) return get_child_element_list @@ -405,15 +431,15 @@ def _public_add_method_name(self): provide a friendlier API to clients having domain appropriate parameter names for required attributes. """ - return 'add_%s' % self._prop_name + return "add_%s" % self._prop_name @lazyproperty def _remove_method_name(self): - return '_remove_%s' % self._prop_name + return "_remove_%s" % self._prop_name @lazyproperty def _new_method_name(self): - return '_new_%s' % self._prop_name + return "_new_%s" % self._prop_name class Choice(_BaseChildElement): @@ -421,12 +447,12 @@ class Choice(_BaseChildElement): Defines a child element belonging to a group, only one of which may appear as a child. """ + @property def nsptagname(self): return self._nsptagname - def populate_class_members( - self, element_cls, group_prop_name, successors): + def populate_class_members(self, element_cls, group_prop_name, successors): """ Add the appropriate methods to *element_cls*. """ @@ -445,50 +471,47 @@ def _add_get_or_change_to_method(self): Add a ``get_or_change_to_x()`` method to the element class for this child element. """ + def get_or_change_to_child(obj): child = getattr(obj, self._prop_name) if child is not None: return child - remove_group_method = getattr( - obj, self._remove_group_method_name - ) + remove_group_method = getattr(obj, self._remove_group_method_name) remove_group_method() add_method = getattr(obj, self._add_method_name) child = add_method() return child get_or_change_to_child.__doc__ = ( - 'Return the ``<%s>`` child, replacing any other group element if' - ' found.' + "Return the ``<%s>`` child, replacing any other group element if" " found." ) % self._nsptagname - self._add_to_class( - self._get_or_change_to_method_name, get_or_change_to_child - ) + self._add_to_class(self._get_or_change_to_method_name, get_or_change_to_child) @property def _prop_name(self): """ Calculate property name from tag name, e.g. a:schemeClr -> schemeClr. """ - if ':' in self._nsptagname: - start = self._nsptagname.index(':') + 1 + if ":" in self._nsptagname: + start = self._nsptagname.index(":") + 1 else: start = 0 return self._nsptagname[start:] @lazyproperty def _get_or_change_to_method_name(self): - return 'get_or_change_to_%s' % self._prop_name + return "get_or_change_to_%s" % self._prop_name @lazyproperty def _remove_group_method_name(self): - return '_remove_%s' % self._group_prop_name + return "_remove_%s" % self._group_prop_name class OneAndOnlyOne(_BaseChildElement): """ Defines a required child element for MetaOxmlElement. """ + def __init__(self, nsptagname): super(OneAndOnlyOne, self).__init__(nsptagname, None) @@ -496,9 +519,7 @@ def populate_class_members(self, element_cls, prop_name): """ Add the appropriate methods to *element_cls*. """ - super(OneAndOnlyOne, self).populate_class_members( - element_cls, prop_name - ) + super(OneAndOnlyOne, self).populate_class_members(element_cls, prop_name) self._add_getter() @property @@ -507,18 +528,17 @@ def _getter(self): Return a function object suitable for the "get" side of the property descriptor. """ + def get_child_element(obj): child = obj.find(qn(self._nsptagname)) if child is None: raise InvalidXmlError( - "required ``<%s>`` child element not present" % - self._nsptagname + "required ``<%s>`` child element not present" % self._nsptagname ) return child get_child_element.__doc__ = ( - 'Required ``<%s>`` child element.' - % self._nsptagname + "Required ``<%s>`` child element." % self._nsptagname ) return get_child_element @@ -528,13 +548,12 @@ class OneOrMore(_BaseChildElement): Defines a repeating child element for MetaOxmlElement that must appear at least once. """ + def populate_class_members(self, element_cls, prop_name): """ Add the appropriate methods to *element_cls*. """ - super(OneOrMore, self).populate_class_members( - element_cls, prop_name - ) + super(OneOrMore, self).populate_class_members(element_cls, prop_name) self._add_list_getter() self._add_creator() self._add_inserter() @@ -547,13 +566,12 @@ class ZeroOrMore(_BaseChildElement): """ Defines an optional repeating child element for MetaOxmlElement. """ + def populate_class_members(self, element_cls, prop_name): """ Add the appropriate methods to *element_cls*. """ - super(ZeroOrMore, self).populate_class_members( - element_cls, prop_name - ) + super(ZeroOrMore, self).populate_class_members(element_cls, prop_name) self._add_list_getter() self._add_creator() self._add_inserter() @@ -566,6 +584,7 @@ class ZeroOrOne(_BaseChildElement): """ Defines an optional child element for MetaOxmlElement. """ + def populate_class_members(self, element_cls, prop_name): """ Add the appropriate methods to *element_cls*. @@ -583,14 +602,16 @@ def _add_get_or_adder(self): Add a ``get_or_add_x()`` method to the element class for this child element. """ + def get_or_add_child(obj): child = getattr(obj, self._prop_name) if child is None: add_method = getattr(obj, self._add_method_name) child = add_method() return child + get_or_add_child.__doc__ = ( - 'Return the ``<%s>`` child element, newly added if not present.' + "Return the ``<%s>`` child element, newly added if not present." ) % self._nsptagname self._add_to_class(self._get_or_add_method_name, get_or_add_child) @@ -599,16 +620,18 @@ def _add_remover(self): Add a ``_remove_x()`` method to the element class for this child element. """ + def _remove_child(obj): obj.remove_all(self._nsptagname) + _remove_child.__doc__ = ( - 'Remove all ``<%s>`` child elements.' + "Remove all ``<%s>`` child elements." ) % self._nsptagname self._add_to_class(self._remove_method_name, _remove_child) @lazyproperty def _get_or_add_method_name(self): - return 'get_or_add_%s' % self._prop_name + return "get_or_add_%s" % self._prop_name class ZeroOrOneChoice(_BaseChildElement): @@ -616,6 +639,7 @@ class ZeroOrOneChoice(_BaseChildElement): Correspondes to an ``EG_*`` element group where at most one of its members may appear as a child. """ + def __init__(self, choices, successors=()): self._choices = choices self._successors = successors @@ -624,9 +648,7 @@ def populate_class_members(self, element_cls, prop_name): """ Add the appropriate methods to *element_cls*. """ - super(ZeroOrOneChoice, self).populate_class_members( - element_cls, prop_name - ) + super(ZeroOrOneChoice, self).populate_class_members(element_cls, prop_name) self._add_choice_getter() for choice in self._choices: choice.populate_class_members( @@ -649,16 +671,15 @@ def _add_group_remover(self): Add a ``_remove_eg_x()`` method to the element class for this choice group. """ + def _remove_choice_group(obj): for tagname in self._member_nsptagnames: obj.remove_all(tagname) _remove_choice_group.__doc__ = ( - 'Remove the current choice group child element if present.' - ) - self._add_to_class( - self._remove_choice_group_method_name, _remove_choice_group + "Remove the current choice group child element if present." ) + self._add_to_class(self._remove_choice_group_method_name, _remove_choice_group) @property def _choice_getter(self): @@ -666,11 +687,13 @@ def _choice_getter(self): Return a function object suitable for the "get" side of the property descriptor. """ + def get_group_member_element(obj): return obj.first_child_found_in(*self._member_nsptagnames) + get_group_member_element.__doc__ = ( - 'Return the child element belonging to this element group, or ' - '|None| if no member child is present.' + "Return the child element belonging to this element group, or " + "|None| if no member child is present." ) return get_group_member_element @@ -684,7 +707,7 @@ def _member_nsptagnames(self): @lazyproperty def _remove_choice_group_method_name(self): - return '_remove_%s' % self._prop_name + return "_remove_%s" % self._prop_name class _OxmlElementBase(etree.ElementBase): @@ -699,7 +722,9 @@ class _OxmlElementBase(etree.ElementBase): def __repr__(self): return "<%s '<%s>' at 0x%0x>" % ( - self.__class__.__name__, self._nsptag, id(self) + self.__class__.__name__, + self._nsptag, + id(self), ) def first_child_found_in(self, *tagnames): @@ -745,9 +770,7 @@ def xpath(self, xpath_str): Override of ``lxml`` _Element.xpath() method to provide standard Open XML namespace mapping (``nsmap``) in centralized location. """ - return super(BaseOxmlElement, self).xpath( - xpath_str, namespaces=nsmap - ) + return super(BaseOxmlElement, self).xpath(xpath_str, namespaces=nsmap) @property def _nsptag(self): @@ -755,5 +778,5 @@ def _nsptag(self): BaseOxmlElement = MetaOxmlElement( - 'BaseOxmlElement', (etree.ElementBase,), dict(_OxmlElementBase.__dict__) + "BaseOxmlElement", (etree.ElementBase,), dict(_OxmlElementBase.__dict__) ) diff --git a/docx/package.py b/docx/package.py index 9f5ccc667..7e70b1531 100644 --- a/docx/package.py +++ b/docx/package.py @@ -107,7 +107,7 @@ def _next_image_partname(self, ext): def image_partname(n): return PackURI('/word/media/image%d.%s' % (n, ext)) used_numbers = [image_part.partname.idx for image_part in self] - for n in range(1, len(self)+1): + for n in range(1, len(self) + 1): if n not in used_numbers: return image_partname(n) - return image_partname(len(self)+1) + return image_partname(len(self) + 1) diff --git a/docx/parts/document.py b/docx/parts/document.py index 59d0b7a71..796bae89b 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -4,8 +4,12 @@ from __future__ import absolute_import, division, print_function, unicode_literals +from itertools import chain + +from docx.bookmark import Bookmarks from docx.document import Document from docx.opc.constants import RELATIONSHIP_TYPE as RT +from docx.oxml.shape import CT_Inline from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.numbering import NumberingPart from docx.parts.settings import SettingsPart @@ -36,6 +40,11 @@ def add_header_part(self): rId = self.relate_to(header_part, RT.HEADER) return header_part, rId + @lazyproperty + def bookmarks(self): + """Singleton |Bookmarks| object for this docx package.""" + return Bookmarks(self) + @property def core_properties(self): """ @@ -89,6 +98,45 @@ def inline_shapes(self): """ return InlineShapes(self._element.body, self) + def iter_story_parts(self): + """Generate all parts in document that contain a story. + + A story is a sequence of block-level items (paragraphs and tables). + Story parts include this main document part, headers, footers, + footnotes, and endnotes. + """ + return chain( + (self,), + self.iter_parts_related_by( + {RT.COMMENTS, RT.ENDNOTES, RT.FOOTER, RT.FOOTNOTES, RT.HEADER} + ), + ) + + def new_pic_inline(self, image_descriptor, width, height): + """ + Return a newly-created `w:inline` element containing the image + specified by *image_descriptor* and scaled based on the values of + *width* and *height*. + """ + 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) + + @property + def next_id(self): + """Next available positive integer id value in this document. + + Calculated by incrementing maximum existing id value. Gaps in the + existing id sequence are not filled. The id attribute value is unique + in the document, without regard to the element type it appears on. + """ + id_str_lst = self._element.xpath("//@id") + used_ids = [int(id_str) for id_str in id_str_lst if id_str.isdigit()] + if not used_ids: + return 1 + return max(used_ids) + 1 + @lazyproperty def numbering_part(self): """ diff --git a/docx/parts/endnotes.py b/docx/parts/endnotes.py new file mode 100644 index 000000000..57d48e4ba --- /dev/null +++ b/docx/parts/endnotes.py @@ -0,0 +1,11 @@ +# encoding: utf-8 + +"""|EndnotesPart| and closely related objects""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +from docx.opc.part import XmlPart + + +class EndnotesPart(XmlPart): + """Package part containing end-notes""" diff --git a/docx/parts/footnotes.py b/docx/parts/footnotes.py new file mode 100644 index 000000000..a1fb0f4e8 --- /dev/null +++ b/docx/parts/footnotes.py @@ -0,0 +1,11 @@ +# encoding: utf-8 + +"""|FootnotesPart| and related objects""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +from docx.opc.part import XmlPart + + +class FootnotesPart(XmlPart): + """Package part containing footnotes""" diff --git a/docx/parts/hdrftr.py b/docx/parts/hdrftr.py index 549805b2a..22ea874a0 100644 --- a/docx/parts/hdrftr.py +++ b/docx/parts/hdrftr.py @@ -26,9 +26,9 @@ def new(cls, package): def _default_footer_xml(cls): """Return bytes containing XML for a default footer part.""" path = os.path.join( - os.path.split(__file__)[0], '..', 'templates', 'default-footer.xml' + os.path.split(__file__)[0], "..", "templates", "default-footer.xml" ) - with open(path, 'rb') as f: + with open(path, "rb") as f: xml_bytes = f.read() return xml_bytes @@ -48,8 +48,8 @@ def new(cls, package): def _default_header_xml(cls): """Return bytes containing XML for a default header part.""" path = os.path.join( - os.path.split(__file__)[0], '..', 'templates', 'default-header.xml' + os.path.split(__file__)[0], "..", "templates", "default-header.xml" ) - with open(path, 'rb') as f: + with open(path, "rb") as f: xml_bytes = f.read() return xml_bytes diff --git a/docx/parts/story.py b/docx/parts/story.py index 129b8f1cc..6a2692f6d 100644 --- a/docx/parts/story.py +++ b/docx/parts/story.py @@ -18,6 +18,11 @@ class BaseStoryPart(XmlPart): `.add_paragraph()`, `.add_table()` etc. """ + @lazyproperty + def bookmarks(self): + """Global |Bookmarks| object for this docx package.""" + return self._document_part.bookmarks + def get_or_add_image(self, image_descriptor): """Return (rId, image) pair for image identified by *image_descriptor*. @@ -66,7 +71,7 @@ def next_id(self): the existing id sequence are not filled. The id attribute value is unique in the document, without regard to the element type it appears on. """ - id_str_lst = self._element.xpath('//@id') + id_str_lst = self._element.xpath("//@id") used_ids = [int(id_str) for id_str in id_str_lst if id_str.isdigit()] if not used_ids: return 1 diff --git a/features/bmk-bookmarks.feature b/features/bmk-bookmarks.feature new file mode 100644 index 000000000..03de53006 --- /dev/null +++ b/features/bmk-bookmarks.feature @@ -0,0 +1,23 @@ +Feature: Access a bookmark + In order to operate on document bookmark objects + As a developer using python-docx + I need sequence operations on Bookmarks + + + Scenario: Bookmarks is a sequence + Given a Bookmarks object of length 5 as bookmarks + Then len(bookmarks) == 5 + And bookmarks[1] is a _Bookmark object + And iterating bookmarks produces 5 _Bookmark objects + + Scenario Outline: Bookmarks.get(bookmark_name) + Given a Bookmarks object of length 5 as bookmarks + Then bookmarks.get() returns bookmark named "" with id + + Examples: Named Bookmarks + | name | id | + | bookmark_body | 2 | + | bookmark_endnote | 1 | + | bookmark_footer | 5 | + | bookmark_footnote | 0 | + | bookmark_header | 4 | diff --git a/features/cel-add-table.feature b/features/cel-add-table.feature deleted file mode 100644 index 5aabcee8f..000000000 --- a/features/cel-add-table.feature +++ /dev/null @@ -1,12 +0,0 @@ -Feature: Add a table into a table cell - In order to nest a table within a table cell - As a developer using python-docx - I need a way to add a table to a table cell - - - Scenario: Add a table into a table cell - Given a table cell - When I add a 2 x 2 table into the first cell - Then cell.tables[0] is a 2 x 2 table - And the width of each column is 1.5375 inches - And the width of each cell is 1.5375 inches diff --git a/features/cel-text.feature b/features/cel-text.feature deleted file mode 100644 index 8373f8ae7..000000000 --- a/features/cel-text.feature +++ /dev/null @@ -1,9 +0,0 @@ -Feature: Set table cell text - In order to quickly populate a table cell with regular text - As a developer using python-docx - I need the ability to set the text of a table cell - - Scenario: Set table cell text - Given a table cell - When I assign a string to the cell text attribute - Then the cell contains the string I assigned diff --git a/features/doc-access-collections.feature b/features/doc-access-collections.feature index 0233d5989..5c1b8d64b 100644 --- a/features/doc-access-collections.feature +++ b/features/doc-access-collections.feature @@ -27,3 +27,8 @@ Feature: Access document collections Scenario: Access the tables collection of a document Given a document having three tables Then document.tables is a list containing three tables + + + Scenario: Document.bookmarks + Given a Document object as document + Then document.bookmarks is a Bookmarks object diff --git a/features/doc-document.feature b/features/doc-document.feature new file mode 100644 index 000000000..12da3deb0 --- /dev/null +++ b/features/doc-document.feature @@ -0,0 +1,17 @@ +Feature: Document properties and methods + In order to manipulate a Word document + As a developer using python-docx + I need properties and methods on the Document object + + Scenario: Document.start_bookmark() + Given a Document object as document + When I assign bookmark = document.start_bookmark("Target") + Then bookmark.name == "Target" + And bookmark.id is an int + + @wip + Scenario: Document.end_bookmark() + Given a Document object as document + And an open Bookmark object named "Target" as bookmark + Then bookmark == document.end_bookmark(bookmark) + And bookmark == document.bookmarks.get("Target") diff --git a/features/hdr-header-footer.feature b/features/hdr-header-footer.feature index eb2bb00d6..557973258 100644 --- a/features/hdr-header-footer.feature +++ b/features/hdr-header-footer.feature @@ -46,6 +46,18 @@ Feature: Header and footer behaviors Then I can't detect the image but no exception is raised + Scenario Outline: _Header.start_bookmark() + Given a _Header object header definition as header + When I assign bookmark = header.start_bookmark("Target") + Then bookmark.name == "Target" + And bookmark.id is an int + + Examples: _Header definition states + | with-or-no | + | with a | + | with no | + + Scenario Outline: _Footer.is_linked_to_previous getter Given a _Footer object footer definition as footer Then footer.is_linked_to_previous is @@ -86,3 +98,15 @@ Feature: Header and footer behaviors Given a _Run object from a footer as run When I call run.add_picture() Then I can't detect the image but no exception is raised + + + Scenario Outline: _Footer.start_bookmark() + Given a _Footer object footer definition as footer + When I assign bookmark = footer.start_bookmark("Target") + Then bookmark.name == "Target" + And bookmark.id is an int + + Examples: _Footer definition states + | with-or-no | + | with a | + | with no | diff --git a/features/steps/bookmarks.py b/features/steps/bookmarks.py new file mode 100644 index 000000000..88b7771ea --- /dev/null +++ b/features/steps/bookmarks.py @@ -0,0 +1,64 @@ +# encoding: utf-8 + +"""Step implementations for bookmark-related features.""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +from behave import given, then + +from docx import Document + +from helpers import test_docx + + +# given =================================================== + + +@given("a Bookmarks object of length 5 as bookmarks") +def given_a_Bookmarks_object_of_length_5_as_bookmarks(context): + document = Document(test_docx("bmk-bookmarks")) + context.bookmarks = document.bookmarks + + +# then ===================================================== + + +@then("bookmark.id is an int") +def then_bookmark_id_is_an_int(context): + bookmark = context.bookmark + assert isinstance(bookmark.id, int) + + +@then('bookmark.name == "Target"') +def then_bookmark_name_eq_Target(context): + bookmark = context.bookmark + assert bookmark.name == "Target" + + +@then('bookmarks.get({name}) returns bookmark named "{name}" with id {id}') +def then_bookmark_get_returns_bookmark_object(context, name, id): + bookmark = context.bookmarks.get(name) + assert bookmark.name == name + assert bookmark.id == int(id) + + +@then("bookmarks[{idx}] is a _Bookmark object") +def then_bookmarks_idx_is_a_Bookmark_object(context, idx): + item = context.bookmarks[int(idx)] + expected = "_Bookmark" + actual = item.__class__.__name__ + assert actual == expected, "bookmarks[%s] is a %s object" % (idx, actual) + + +@then("iterating bookmarks produces {n} _Bookmark objects") +def then_iterating_bookmarks_produces_n_Bookmark_objects(context, n): + items = [item for item in context.bookmarks] + assert len(items) == int(n) + assert all(item.__class__.__name__ == "_Bookmark" for item in items) + + +@then("len(bookmarks) == {count}") +def then_len_bookmarks_eq_count(context, count): + expected = int(count) + actual = len(context.bookmarks) + assert actual == expected, "len(bookmarks) == %s" % actual diff --git a/features/steps/cell.py b/features/steps/cell.py deleted file mode 100644 index d1385c921..000000000 --- a/features/steps/cell.py +++ /dev/null @@ -1,54 +0,0 @@ -# encoding: utf-8 - -""" -Step implementations for table cell-related features -""" - -from __future__ import absolute_import, print_function, unicode_literals - -from behave import given, then, when - -from docx import Document - -from helpers import test_docx - - -# given =================================================== - -@given('a table cell') -def given_a_table_cell(context): - table = Document(test_docx('tbl-2x2-table')).tables[0] - context.cell = table.cell(0, 0) - - -# when ===================================================== - -@when('I add a 2 x 2 table into the first cell') -def when_I_add_a_2x2_table_into_the_first_cell(context): - context.table_ = context.cell.add_table(2, 2) - - -@when('I assign a string to the cell text attribute') -def when_assign_string_to_cell_text_attribute(context): - cell = context.cell - text = 'foobar' - cell.text = text - context.expected_text = text - - -# then ===================================================== - -@then('cell.tables[0] is a 2 x 2 table') -def then_cell_tables_0_is_a_2x2_table(context): - cell = context.cell - table = cell.tables[0] - assert len(table.rows) == 2 - assert len(table.columns) == 2 - - -@then('the cell contains the string I assigned') -def then_cell_contains_string_assigned(context): - cell, expected_text = context.cell, context.expected_text - text = cell.paragraphs[0].runs[0].text - msg = "expected '%s', got '%s'" % (expected_text, text) - assert text == expected_text, msg diff --git a/features/steps/document.py b/features/steps/document.py index a8e4d1adf..c52531433 100644 --- a/features/steps/document.py +++ b/features/steps/document.py @@ -1,10 +1,8 @@ # encoding: utf-8 -""" -Step implementations for document-related features -""" +"""Step implementations for document-related features""" -from __future__ import absolute_import, print_function, unicode_literals +from __future__ import absolute_import, division, print_function, unicode_literals from behave import given, then, when @@ -22,39 +20,45 @@ # given =================================================== -@given('a blank document') + +@given("a blank document") def given_a_blank_document(context): - context.document = Document(test_docx('doc-word-default-blank')) + context.document = Document(test_docx("doc-word-default-blank")) + + +@given("a Document object as document") +def given_a_Document_object_as_document(context): + context.document = Document(test_docx("doc-default")) -@given('a document having built-in styles') +@given("a document having built-in styles") def given_a_document_having_builtin_styles(context): context.document = Document() -@given('a document having inline shapes') +@given("a document having inline shapes") def given_a_document_having_inline_shapes(context): - context.document = Document(test_docx('shp-inline-shape-access')) + context.document = Document(test_docx("shp-inline-shape-access")) -@given('a document having sections') +@given("a document having sections") def given_a_document_having_sections(context): - context.document = Document(test_docx('doc-access-sections')) + context.document = Document(test_docx("doc-access-sections")) -@given('a document having styles') +@given("a document having styles") def given_a_document_having_styles(context): - context.document = Document(test_docx('sty-having-styles-part')) + context.document = Document(test_docx("sty-having-styles-part")) -@given('a document having three tables') +@given("a document having three tables") def given_a_document_having_three_tables(context): - context.document = Document(test_docx('tbl-having-tables')) + context.document = Document(test_docx("tbl-having-tables")) -@given('a single-section document having portrait layout') +@given("a single-section document having portrait layout") def given_a_single_section_document_having_portrait_layout(context): - context.document = Document(test_docx('doc-add-section')) + context.document = Document(test_docx("doc-add-section")) section = context.document.sections[-1] context.original_dimensions = (section.page_width, section.page_height) @@ -64,57 +68,60 @@ def given_a_single_section_Document_object_with_headers_and_footers(context): context.document = Document(test_docx("doc-add-section")) +@given('an open Bookmark object named "Target" as bookmark') +def given_an_open_Bookmark_object_named_Target_as_bookmark(context): + context.bookmark = context.document.start_bookmark("Target") + + # when ==================================================== -@when('I add a 2 x 2 table specifying only row and column count') + +@when("I add a 2 x 2 table specifying only row and column count") def when_add_2x2_table_specifying_only_row_and_col_count(context): document = context.document document.add_table(rows=2, cols=2) -@when('I add a 2 x 2 table specifying style \'{style_name}\'') +@when("I add a 2 x 2 table specifying style '{style_name}'") def when_add_2x2_table_specifying_style_name(context, style_name): document = context.document document.add_table(rows=2, cols=2, style=style_name) -@when('I add a heading specifying level={level}') +@when("I add a heading specifying level={level}") def when_add_heading_specifying_level(context, level): context.document.add_heading(level=int(level)) -@when('I add a heading specifying only its text') +@when("I add a heading specifying only its text") def when_add_heading_specifying_only_its_text(context): document = context.document - context.heading_text = text = 'Spam vs. Eggs' + context.heading_text = text = "Spam vs. Eggs" document.add_heading(text) -@when('I add a page break to the document') +@when("I add a page break to the document") def when_add_page_break_to_document(context): document = context.document document.add_page_break() -@when('I add a paragraph specifying its style as a {kind}') +@when("I add a paragraph specifying its style as a {kind}") def when_I_add_a_paragraph_specifying_its_style_as_a(context, kind): document = context.document - style = context.style = document.styles['Heading 1'] - style_spec = { - 'style object': style, - 'style name': 'Heading 1', - }[kind] + style = context.style = document.styles["Heading 1"] + style_spec = {"style object": style, "style name": "Heading 1"}[kind] document.add_paragraph(style=style_spec) -@when('I add a paragraph specifying its text') +@when("I add a paragraph specifying its text") def when_add_paragraph_specifying_text(context): document = context.document - context.paragraph_text = 'foobar' + context.paragraph_text = "foobar" document.add_paragraph(context.paragraph_text) -@when('I add a paragraph without specifying text or style') +@when("I add a paragraph without specifying text or style") def when_add_paragraph_without_specifying_text_or_style(context): document = context.document document.add_paragraph() @@ -124,39 +131,44 @@ def when_add_paragraph_without_specifying_text_or_style(context): def when_add_picture_specifying_width_and_height(context): document = context.document context.picture = document.add_picture( - test_file('monty-truth.png'), - width=Inches(1.75), height=Inches(2.5) + test_file("monty-truth.png"), width=Inches(1.75), height=Inches(2.5) ) -@when('I add a picture specifying a height of 1.5 inches') +@when("I add a picture specifying a height of 1.5 inches") def when_add_picture_specifying_height(context): document = context.document context.picture = document.add_picture( - test_file('monty-truth.png'), height=Inches(1.5) + test_file("monty-truth.png"), height=Inches(1.5) ) -@when('I add a picture specifying a width of 1.5 inches') +@when("I add a picture specifying a width of 1.5 inches") def when_add_picture_specifying_width(context): document = context.document context.picture = document.add_picture( - test_file('monty-truth.png'), width=Inches(1.5) + test_file("monty-truth.png"), width=Inches(1.5) ) -@when('I add a picture specifying only the image file') +@when("I add a picture specifying only the image file") def when_add_picture_specifying_only_image_file(context): document = context.document - context.picture = document.add_picture(test_file('monty-truth.png')) + context.picture = document.add_picture(test_file("monty-truth.png")) -@when('I add an even-page section to the document') +@when("I add an even-page section to the document") def when_I_add_an_even_page_section_to_the_document(context): context.section = context.document.add_section(WD_SECTION.EVEN_PAGE) -@when('I change the new section layout to landscape') +@when('I assign bookmark = document.start_bookmark("Target")') +def when_I_assign_bookmark_eq_document_start_bookmark(context): + document = context.document + context.bookmark = document.start_bookmark("Target") + + +@when("I change the new section layout to landscape") def when_I_change_the_new_section_layout_to_landscape(context): new_height, new_width = context.original_dimensions section = context.section @@ -172,14 +184,33 @@ def when_I_execute_section_eq_document_add_section(context): # then ==================================================== -@then('document.inline_shapes is an InlineShapes object') + +@then('bookmark == document.bookmarks.get("Target")') +def then_bookmark_eq_document_bookmarks_get_Target(context): + assert context.bookmark == context.document.bookmarks.get("Target") + + +@then("bookmark == document.end_bookmark(bookmark)") +def then_bookmark_eq_document_end_bookmark(context): + bookmark = context.bookmark + assert bookmark == context.document.end_bookmark(bookmark) + + +@then("document.bookmarks is a Bookmarks object") +def then_document_bookmarks_is_a_Bookmarks_object(context): + actual = context.document.bookmarks.__class__.__name__ + expected = "Bookmarks" + assert actual == expected, "document.bookmarks is a %s object" % actual + + +@then("document.inline_shapes is an InlineShapes object") def then_document_inline_shapes_is_an_InlineShapes_object(context): document = context.document inline_shapes = document.inline_shapes assert isinstance(inline_shapes, InlineShapes) -@then('document.paragraphs is a list containing three paragraphs') +@then("document.paragraphs is a list containing three paragraphs") def then_document_paragraphs_is_a_list_containing_three_paragraphs(context): document = context.document paragraphs = document.paragraphs @@ -189,20 +220,20 @@ def then_document_paragraphs_is_a_list_containing_three_paragraphs(context): assert isinstance(paragraph, Paragraph) -@then('document.sections is a Sections object') +@then("document.sections is a Sections object") def then_document_sections_is_a_Sections_object(context): sections = context.document.sections - msg = 'document.sections not instance of Sections' + msg = "document.sections not instance of Sections" assert isinstance(sections, Sections), msg -@then('document.styles is a Styles object') +@then("document.styles is a Styles object") def then_document_styles_is_a_Styles_object(context): styles = context.document.styles assert isinstance(styles, Styles) -@then('document.tables is a list containing three tables') +@then("document.tables is a list containing three tables") def then_document_tables_is_a_list_containing_three_tables(context): document = context.document tables = document.tables @@ -212,7 +243,7 @@ def then_document_tables_is_a_list_containing_three_tables(context): assert isinstance(table, Table) -@then('the document contains a 2 x 2 table') +@then("the document contains a 2 x 2 table") def then_the_document_contains_a_2x2_table(context): table = context.document.tables[-1] assert isinstance(table, Table) @@ -221,12 +252,12 @@ def then_the_document_contains_a_2x2_table(context): context.table_ = table -@then('the document has two sections') +@then("the document has two sections") def then_the_document_has_two_sections(context): assert len(context.document.sections) == 2 -@then('the first section is portrait') +@then("the first section is portrait") def then_the_first_section_is_portrait(context): first_section = context.document.sections[0] expected_width, expected_height = context.original_dimensions @@ -235,16 +266,16 @@ def then_the_first_section_is_portrait(context): assert first_section.page_height == expected_height -@then('the last paragraph contains only a page break') +@then("the last paragraph contains only a page break") def then_last_paragraph_contains_only_a_page_break(context): document = context.document paragraph = document.paragraphs[-1] assert len(paragraph.runs) == 1 assert len(paragraph.runs[0]._r) == 1 - assert paragraph.runs[0]._r[0].type == 'page' + assert paragraph.runs[0]._r[0].type == "page" -@then('the last paragraph contains the heading text') +@then("the last paragraph contains the heading text") def then_last_p_contains_heading_text(context): document = context.document text = context.heading_text @@ -252,7 +283,7 @@ def then_last_p_contains_heading_text(context): assert paragraph.text == text -@then('the second section is landscape') +@then("the second section is landscape") def then_the_second_section_is_landscape(context): new_section = context.document.sections[-1] expected_height, expected_width = context.original_dimensions @@ -261,10 +292,8 @@ def then_the_second_section_is_landscape(context): assert new_section.page_height == expected_height -@then('the style of the last paragraph is \'{style_name}\'') +@then("the style of the last paragraph is '{style_name}'") def then_the_style_of_the_last_paragraph_is_style(context, style_name): document = context.document paragraph = document.paragraphs[-1] - assert paragraph.style.name == style_name, ( - 'got %s' % paragraph.style.name - ) + assert paragraph.style.name == style_name, "got %s" % paragraph.style.name diff --git a/features/steps/hdrftr.py b/features/steps/hdrftr.py index 786673dbd..e6685db20 100644 --- a/features/steps/hdrftr.py +++ b/features/steps/hdrftr.py @@ -13,6 +13,7 @@ # given ==================================================== + @given("a _Footer object {with_or_no} footer definition as footer") def given_a_Footer_object_with_or_no_footer_definition(context, with_or_no): section_idx = {"with a": 0, "with no": 1}[with_or_no] @@ -51,12 +52,25 @@ def given_the_next_Header_object_with_no_header_definition(context): # when ===================================================== -@when("I assign \"Normal\" to footer.paragraphs[0].style") + +@when('I assign bookmark = footer.start_bookmark("Target")') +def when_I_assign_bookmark_eq_footer_start_bookmark(context): + footer = context.footer + context.bookmark = footer.start_bookmark("Target") + + +@when('I assign bookmark = header.start_bookmark("Target")') +def when_I_assign_bookmark_eq_header_start_bookmark(context): + header = context.header + context.bookmark = header.start_bookmark("Target") + + +@when('I assign "Normal" to footer.paragraphs[0].style') def when_I_assign_Body_Text_to_footer_style(context): context.footer.paragraphs[0].style = "Normal" -@when("I assign \"Normal\" to header.paragraphs[0].style") +@when('I assign "Normal" to header.paragraphs[0].style') def when_I_assign_Body_Text_to_header_style(context): context.header.paragraphs[0].style = "Normal" @@ -78,6 +92,7 @@ def when_I_call_run_add_picture(context): # then ===================================================== + @then("footer.is_linked_to_previous is {value}") def then_footer_is_linked_to_previous_is_value(context, value): actual = context.footer.is_linked_to_previous @@ -85,7 +100,7 @@ def then_footer_is_linked_to_previous_is_value(context, value): assert actual == expected, "footer.is_linked_to_previous is %s" % actual -@then("footer.paragraphs[0].style.name == \"Normal\"") +@then('footer.paragraphs[0].style.name == "Normal"') def then_footer_paragraphs_0_style_name_eq_Normal(context): actual = context.footer.paragraphs[0].style.name expected = "Normal" @@ -113,7 +128,7 @@ def then_header_is_linked_to_previous_is_value(context, value): assert actual == expected, "header.is_linked_to_previous is %s" % actual -@then("header.paragraphs[0].style.name == \"Normal\"") +@then('header.paragraphs[0].style.name == "Normal"') def then_header_paragraphs_0_style_name_eq_Normal(context): actual = context.header.paragraphs[0].style.name expected = "Normal" diff --git a/features/steps/table.py b/features/steps/table.py index dc6001941..e090aaa00 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -4,17 +4,13 @@ Step implementations for table-related features """ -from __future__ import ( - absolute_import, division, print_function, unicode_literals -) +from __future__ import absolute_import, division, print_function, unicode_literals from behave import given, then, when from docx import Document from docx.enum.table import WD_ALIGN_VERTICAL # noqa -from docx.enum.table import ( - WD_ROW_HEIGHT_RULE, WD_TABLE_ALIGNMENT, WD_TABLE_DIRECTION -) +from docx.enum.table import WD_ROW_HEIGHT_RULE, WD_TABLE_ALIGNMENT, WD_TABLE_DIRECTION from docx.shared import Inches from docx.table import _Column, _Columns, _Row, _Rows @@ -23,269 +19,272 @@ # given =================================================== -@given('a 2 x 2 table') + +@given("a 2 x 2 table") def given_a_2x2_table(context): context.table_ = Document().add_table(rows=2, cols=2) -@given('a 3x3 table having {span_state}') +@given("a 3x3 table having {span_state}") def given_a_3x3_table_having_span_state(context, span_state): table_idx = { - 'only uniform cells': 0, - 'a horizontal span': 1, - 'a vertical span': 2, - 'a combined span': 3, + "only uniform cells": 0, + "a horizontal span": 1, + "a vertical span": 2, + "a combined span": 3, }[span_state] - document = Document(test_docx('tbl-cell-access')) + document = Document(test_docx("tbl-cell-access")) context.table_ = document.tables[table_idx] -@given('a _Cell object with {state} vertical alignment as cell') +@given("a _Cell object as cell") +def given_a_Cell_object_as_cell(context): + table = Document(test_docx("tbl-2x2-table")).tables[0] + context.cell = table.cell(0, 0) + + +@given("a _Cell object with {state} vertical alignment as cell") def given_a_Cell_object_with_vertical_alignment_as_cell(context, state): - table_idx = { - 'inherited': 0, - 'bottom': 1, - 'center': 2, - 'top': 3, - }[state] - document = Document(test_docx('tbl-props')) + table_idx = {"inherited": 0, "bottom": 1, "center": 2, "top": 3}[state] + document = Document(test_docx("tbl-props")) table = document.tables[table_idx] context.cell = table.cell(0, 0) -@given('a column collection having two columns') +@given("a column collection having two columns") def given_a_column_collection_having_two_columns(context): - docx_path = test_docx('blk-containing-table') + docx_path = test_docx("blk-containing-table") document = Document(docx_path) context.columns = document.tables[0].columns -@given('a row collection having two rows') +@given("a row collection having two rows") def given_a_row_collection_having_two_rows(context): - docx_path = test_docx('blk-containing-table') + docx_path = test_docx("blk-containing-table") document = Document(docx_path) context.rows = document.tables[0].rows -@given('a table') +@given("a table") def given_a_table(context): context.table_ = Document().add_table(rows=2, cols=2) -@given('a table cell having a width of {width}') +@given("a table cell having a width of {width}") def given_a_table_cell_having_a_width_of_width(context, width): - table_idx = {'no explicit setting': 0, '1 inch': 1, '2 inches': 2}[width] - document = Document(test_docx('tbl-props')) + table_idx = {"no explicit setting": 0, "1 inch": 1, "2 inches": 2}[width] + document = Document(test_docx("tbl-props")) table = document.tables[table_idx] cell = table.cell(0, 0) context.cell = cell -@given('a table column having a width of {width_desc}') +@given("a table column having a width of {width_desc}") def given_a_table_having_a_width_of_width_desc(context, width_desc): - col_idx = { - 'no explicit setting': 0, - '1440': 1, - }[width_desc] - docx_path = test_docx('tbl-col-props') + col_idx = {"no explicit setting": 0, "1440": 1}[width_desc] + docx_path = test_docx("tbl-col-props") document = Document(docx_path) context.column = document.tables[0].columns[col_idx] -@given('a table having {alignment} alignment') +@given("a table having {alignment} alignment") def given_a_table_having_alignment_alignment(context, alignment): - table_idx = { - 'inherited': 3, - 'left': 4, - 'right': 5, - 'center': 6, - }[alignment] - docx_path = test_docx('tbl-props') + table_idx = {"inherited": 3, "left": 4, "right": 5, "center": 6}[alignment] + docx_path = test_docx("tbl-props") document = Document(docx_path) context.table_ = document.tables[table_idx] -@given('a table having an autofit layout of {autofit}') +@given("a table having an autofit layout of {autofit}") def given_a_table_having_an_autofit_layout_of_autofit(context, autofit): - tbl_idx = { - 'no explicit setting': 0, - 'autofit': 1, - 'fixed': 2, - }[autofit] - document = Document(test_docx('tbl-props')) + tbl_idx = {"no explicit setting": 0, "autofit": 1, "fixed": 2}[autofit] + document = Document(test_docx("tbl-props")) context.table_ = document.tables[tbl_idx] -@given('a table having {style} style') +@given("a table having {style} style") def given_a_table_having_style(context, style): - table_idx = { - 'no explicit': 0, - 'Table Grid': 1, - 'Light Shading - Accent 1': 2, - }[style] - document = Document(test_docx('tbl-having-applied-style')) + table_idx = {"no explicit": 0, "Table Grid": 1, "Light Shading - Accent 1": 2}[ + style + ] + document = Document(test_docx("tbl-having-applied-style")) context.document = document context.table_ = document.tables[table_idx] -@given('a table having table direction set {setting}') +@given("a table having table direction set {setting}") def given_a_table_having_table_direction_setting(context, setting): - table_idx = [ - 'to inherit', - 'right-to-left', - 'left-to-right' - ].index(setting) - document = Document(test_docx('tbl-on-off-props')) + table_idx = ["to inherit", "right-to-left", "left-to-right"].index(setting) + document = Document(test_docx("tbl-on-off-props")) context.table_ = document.tables[table_idx] -@given('a table having two columns') +@given("a table having two columns") def given_a_table_having_two_columns(context): - docx_path = test_docx('blk-containing-table') + docx_path = test_docx("blk-containing-table") document = Document(docx_path) # context.table is used internally by behave, underscore added # to distinguish this one context.table_ = document.tables[0] -@given('a table having two rows') +@given("a table having two rows") def given_a_table_having_two_rows(context): - docx_path = test_docx('blk-containing-table') + docx_path = test_docx("blk-containing-table") document = Document(docx_path) context.table_ = document.tables[0] -@given('a table row having height of {state}') +@given("a table row having height of {state}") def given_a_table_row_having_height_of_state(context, state): - table_idx = { - 'no explicit setting': 0, - '2 inches': 2, - '3 inches': 3 - }[state] - document = Document(test_docx('tbl-props')) + table_idx = {"no explicit setting": 0, "2 inches": 2, "3 inches": 3}[state] + document = Document(test_docx("tbl-props")) table = document.tables[table_idx] context.row = table.rows[0] -@given('a table row having height rule {state}') +@given("a table row having height rule {state}") def given_a_table_row_having_height_rule_state(context, state): - table_idx = { - 'no explicit setting': 0, - 'automatic': 1, - 'at least': 2, - 'exactly': 3 - }[state] - document = Document(test_docx('tbl-props')) + table_idx = {"no explicit setting": 0, "automatic": 1, "at least": 2, "exactly": 3}[ + state + ] + document = Document(test_docx("tbl-props")) table = document.tables[table_idx] context.row = table.rows[0] # when ===================================================== -@when('I add a 1.0 inch column to the table') + +@when("I add a 1.0 inch column to the table") def when_I_add_a_1_inch_column_to_table(context): context.column = context.table_.add_column(Inches(1.0)) -@when('I add a row to the table') +@when("I add a 2 x 2 table into the first cell") +def when_I_add_a_2x2_table_into_the_first_cell(context): + context.table_ = context.cell.add_table(2, 2) + + +@when("I add a row to the table") def when_add_row_to_table(context): table = context.table_ context.row = table.add_row() -@when('I assign {value} to cell.vertical_alignment') +@when("I assign a string to the cell text attribute") +def when_assign_string_to_cell_text_attribute(context): + cell = context.cell + text = "foobar" + cell.text = text + context.expected_text = text + + +@when('I assign bookmark = cell.start_bookmark("Target")') +def when_I_assign_bookmark_eq_cell_start_bookmark(context): + cell = context.cell + context.bookmark = cell.start_bookmark("Target") + + +@when("I assign {value} to cell.vertical_alignment") def when_I_assign_value_to_cell_vertical_alignment(context, value): context.cell.vertical_alignment = eval(value) -@when('I assign {value} to row.height') +@when("I assign {value} to row.height") def when_I_assign_value_to_row_height(context, value): - new_value = None if value == 'None' else int(value) + new_value = None if value == "None" else int(value) context.row.height = new_value -@when('I assign {value} to row.height_rule') +@when("I assign {value} to row.height_rule") def when_I_assign_value_to_row_height_rule(context, value): - new_value = ( - None if value == 'None' else getattr(WD_ROW_HEIGHT_RULE, value) - ) + new_value = None if value == "None" else getattr(WD_ROW_HEIGHT_RULE, value) context.row.height_rule = new_value -@when('I assign {value_str} to table.alignment') +@when("I assign {value_str} to table.alignment") def when_I_assign_value_to_table_alignment(context, value_str): value = { - 'None': None, - 'WD_TABLE_ALIGNMENT.LEFT': WD_TABLE_ALIGNMENT.LEFT, - 'WD_TABLE_ALIGNMENT.RIGHT': WD_TABLE_ALIGNMENT.RIGHT, - 'WD_TABLE_ALIGNMENT.CENTER': WD_TABLE_ALIGNMENT.CENTER, + "None": None, + "WD_TABLE_ALIGNMENT.LEFT": WD_TABLE_ALIGNMENT.LEFT, + "WD_TABLE_ALIGNMENT.RIGHT": WD_TABLE_ALIGNMENT.RIGHT, + "WD_TABLE_ALIGNMENT.CENTER": WD_TABLE_ALIGNMENT.CENTER, }[value_str] table = context.table_ table.alignment = value -@when('I assign {value} to table.style') +@when("I assign {value} to table.style") def when_apply_value_to_table_style(context, value): table, styles = context.table_, context.document.styles - if value == 'None': + if value == "None": new_value = None - elif value.startswith('styles['): - new_value = styles[value.split('\'')[1]] + elif value.startswith("styles["): + new_value = styles[value.split("'")[1]] else: new_value = styles[value] table.style = new_value -@when('I assign {value} to table.table_direction') +@when("I assign {value} to table.table_direction") def when_assign_value_to_table_table_direction(context, value): - new_value = ( - None if value == 'None' else getattr(WD_TABLE_DIRECTION, value) - ) + new_value = None if value == "None" else getattr(WD_TABLE_DIRECTION, value) context.table_.table_direction = new_value -@when('I merge from cell {origin} to cell {other}') +@when("I merge from cell {origin} to cell {other}") def when_I_merge_from_cell_origin_to_cell_other(context, origin, other): def cell(table, idx): row, col = idx // 3, idx % 3 return table.cell(row, col) + a_idx, b_idx = int(origin) - 1, int(other) - 1 table = context.table_ a, b = cell(table, a_idx), cell(table, b_idx) a.merge(b) -@when('I set the cell width to {width}') +@when("I set the cell width to {width}") def when_I_set_the_cell_width_to_width(context, width): - new_value = {'1 inch': Inches(1)}[width] + new_value = {"1 inch": Inches(1)}[width] context.cell.width = new_value -@when('I set the column width to {width_emu}') +@when("I set the column width to {width_emu}") def when_I_set_the_column_width_to_width_emu(context, width_emu): - new_value = None if width_emu == 'None' else int(width_emu) + new_value = None if width_emu == "None" else int(width_emu) context.column.width = new_value -@when('I set the table autofit to {setting}') +@when("I set the table autofit to {setting}") def when_I_set_the_table_autofit_to_setting(context, setting): - new_value = {'autofit': True, 'fixed': False}[setting] + new_value = {"autofit": True, "fixed": False}[setting] table = context.table_ table.autofit = new_value # then ===================================================== -@then('cell.vertical_alignment is {value}') + +@then("cell.tables[0] is a 2 x 2 table") +def then_cell_tables_0_is_a_2x2_table(context): + cell = context.cell + table = cell.tables[0] + assert len(table.rows) == 2 + assert len(table.columns) == 2 + + +@then("cell.vertical_alignment is {value}") def then_cell_vertical_alignment_is_value(context, value): expected_value = eval(value) actual_value = context.cell.vertical_alignment assert actual_value is expected_value, ( - 'cell.vertical_alignment is %s' % actual_value + "cell.vertical_alignment is %s" % actual_value ) -@then('I can access a collection column by index') +@then("I can access a collection column by index") def then_can_access_collection_column_by_index(context): columns = context.columns for idx in range(2): @@ -293,7 +292,7 @@ def then_can_access_collection_column_by_index(context): assert isinstance(column, _Column) -@then('I can access a collection row by index') +@then("I can access a collection row by index") def then_can_access_collection_row_by_index(context): rows = context.rows for idx in range(2): @@ -301,21 +300,21 @@ def then_can_access_collection_row_by_index(context): assert isinstance(row, _Row) -@then('I can access the column collection of the table') +@then("I can access the column collection of the table") def then_can_access_column_collection_of_table(context): table = context.table_ columns = table.columns assert isinstance(columns, _Columns) -@then('I can access the row collection of the table') +@then("I can access the row collection of the table") def then_can_access_row_collection_of_table(context): table = context.table_ rows = table.rows assert isinstance(rows, _Rows) -@then('I can iterate over the column collection') +@then("I can iterate over the column collection") def then_can_iterate_over_column_collection(context): columns = context.columns actual_count = 0 @@ -325,7 +324,7 @@ def then_can_iterate_over_column_collection(context): assert actual_count == 2 -@then('I can iterate over the row collection') +@then("I can iterate over the row collection") def then_can_iterate_over_row_collection(context): rows = context.rows actual_count = 0 @@ -335,163 +334,169 @@ def then_can_iterate_over_row_collection(context): assert actual_count == 2 -@then('row.height is {value}') +@then("row.height is {value}") def then_row_height_is_value(context, value): - expected_height = None if value == 'None' else int(value) + expected_height = None if value == "None" else int(value) actual_height = context.row.height - assert actual_height == expected_height, ( - 'expected %s, got %s' % (expected_height, actual_height) + assert actual_height == expected_height, "expected %s, got %s" % ( + expected_height, + actual_height, ) -@then('row.height_rule is {value}') +@then("row.height_rule is {value}") def then_row_height_rule_is_value(context, value): - expected_rule = ( - None if value == 'None' else getattr(WD_ROW_HEIGHT_RULE, value) - ) + expected_rule = None if value == "None" else getattr(WD_ROW_HEIGHT_RULE, value) actual_rule = context.row.height_rule - assert actual_rule == expected_rule, ( - 'expected %s, got %s' % (expected_rule, actual_rule) + assert actual_rule == expected_rule, "expected %s, got %s" % ( + expected_rule, + actual_rule, ) -@then('table.alignment is {value_str}') +@then("table.alignment is {value_str}") def then_table_alignment_is_value(context, value_str): value = { - 'None': None, - 'WD_TABLE_ALIGNMENT.LEFT': WD_TABLE_ALIGNMENT.LEFT, - 'WD_TABLE_ALIGNMENT.RIGHT': WD_TABLE_ALIGNMENT.RIGHT, - 'WD_TABLE_ALIGNMENT.CENTER': WD_TABLE_ALIGNMENT.CENTER, + "None": None, + "WD_TABLE_ALIGNMENT.LEFT": WD_TABLE_ALIGNMENT.LEFT, + "WD_TABLE_ALIGNMENT.RIGHT": WD_TABLE_ALIGNMENT.RIGHT, + "WD_TABLE_ALIGNMENT.CENTER": WD_TABLE_ALIGNMENT.CENTER, }[value_str] table = context.table_ - assert table.alignment == value, 'got %s' % table.alignment + assert table.alignment == value, "got %s" % table.alignment -@then('table.cell({row}, {col}).text is {expected_text}') +@then("table.cell({row}, {col}).text is {expected_text}") def then_table_cell_row_col_text_is_text(context, row, col, expected_text): table = context.table_ row_idx, col_idx = int(row), int(col) cell_text = table.cell(row_idx, col_idx).text - assert cell_text == expected_text, 'got %s' % cell_text + assert cell_text == expected_text, "got %s" % cell_text -@then('table.style is styles[\'{style_name}\']') +@then("table.style is styles['{style_name}']") def then_table_style_is_styles_style_name(context, style_name): table, styles = context.table_, context.document.styles expected_style = styles[style_name] assert table.style == expected_style, "got '%s'" % table.style -@then('table.table_direction is {value}') +@then("table.table_direction is {value}") def then_table_table_direction_is_value(context, value): - expected_value = ( - None if value == 'None' else getattr(WD_TABLE_DIRECTION, value) - ) + expected_value = None if value == "None" else getattr(WD_TABLE_DIRECTION, value) actual_value = context.table_.table_direction assert actual_value == expected_value, "got '%s'" % actual_value -@then('the column cells text is {expected_text}') +@then("the cell contains the string I assigned") +def then_cell_contains_string_assigned(context): + cell, expected_text = context.cell, context.expected_text + text = cell.paragraphs[0].runs[0].text + msg = "expected '%s', got '%s'" % (expected_text, text) + assert text == expected_text, msg + + +@then("the column cells text is {expected_text}") def then_the_column_cells_text_is_expected_text(context, expected_text): table = context.table_ - cells_text = ' '.join(c.text for col in table.columns for c in col.cells) - assert cells_text == expected_text, 'got %s' % cells_text + cells_text = " ".join(c.text for col in table.columns for c in col.cells) + assert cells_text == expected_text, "got %s" % cells_text -@then('the length of the column collection is 2') +@then("the length of the column collection is 2") def then_len_of_column_collection_is_2(context): columns = context.table_.columns assert len(columns) == 2 -@then('the length of the row collection is 2') +@then("the length of the row collection is 2") def then_len_of_row_collection_is_2(context): rows = context.table_.rows assert len(rows) == 2 -@then('the new column has 2 cells') +@then("the new column has 2 cells") def then_new_column_has_2_cells(context): assert len(context.column.cells) == 2 -@then('the new column is 1.0 inches wide') +@then("the new column is 1.0 inches wide") def then_new_column_is_1_inches_wide(context): assert context.column.width == Inches(1) -@then('the new row has 2 cells') +@then("the new row has 2 cells") def then_new_row_has_2_cells(context): assert len(context.row.cells) == 2 -@then('the reported autofit setting is {autofit}') +@then("the reported autofit setting is {autofit}") def then_the_reported_autofit_setting_is_autofit(context, autofit): - expected_value = {'autofit': True, 'fixed': False}[autofit] + expected_value = {"autofit": True, "fixed": False}[autofit] table = context.table_ assert table.autofit is expected_value -@then('the reported column width is {width_emu}') +@then("the reported column width is {width_emu}") def then_the_reported_column_width_is_width_emu(context, width_emu): - expected_value = None if width_emu == 'None' else int(width_emu) - assert context.column.width == expected_value, ( - 'got %s' % context.column.width - ) + expected_value = None if width_emu == "None" else int(width_emu) + assert context.column.width == expected_value, "got %s" % context.column.width -@then('the reported width of the cell is {width}') +@then("the reported width of the cell is {width}") def then_the_reported_width_of_the_cell_is_width(context, width): - expected_width = {'None': None, '1 inch': Inches(1)}[width] + expected_width = {"None": None, "1 inch": Inches(1)}[width] actual_width = context.cell.width - assert actual_width == expected_width, ( - 'expected %s, got %s' % (expected_width, actual_width) + assert actual_width == expected_width, "expected %s, got %s" % ( + expected_width, + actual_width, ) -@then('the row cells text is {encoded_text}') +@then("the row cells text is {encoded_text}") def then_the_row_cells_text_is_expected_text(context, encoded_text): - expected_text = encoded_text.replace('\\', '\n') + expected_text = encoded_text.replace("\\", "\n") table = context.table_ - cells_text = ' '.join(c.text for row in table.rows for c in row.cells) - assert cells_text == expected_text, 'got %s' % cells_text + cells_text = " ".join(c.text for row in table.rows for c in row.cells) + assert cells_text == expected_text, "got %s" % cells_text -@then('the table has {count} columns') +@then("the table has {count} columns") def then_table_has_count_columns(context, count): column_count = int(count) columns = context.table_.columns assert len(columns) == column_count -@then('the table has {count} rows') +@then("the table has {count} rows") def then_table_has_count_rows(context, count): row_count = int(count) rows = context.table_.rows assert len(rows) == row_count -@then('the width of cell {n_str} is {inches_str} inches') +@then("the width of cell {n_str} is {inches_str} inches") def then_the_width_of_cell_n_is_x_inches(context, n_str, inches_str): def _cell(table, idx): row, col = idx // 3, idx % 3 return table.cell(row, col) + idx, inches = int(n_str) - 1, float(inches_str) cell = _cell(context.table_, idx) - assert cell.width == Inches(inches), 'got %s' % cell.width.inches + assert cell.width == Inches(inches), "got %s" % cell.width.inches -@then('the width of each cell is {inches} inches') +@then("the width of each cell is {inches} inches") def then_the_width_of_each_cell_is_inches(context, inches): table = context.table_ expected_width = Inches(float(inches)) for cell in table._cells: - assert cell.width == expected_width, 'got %s' % cell.width.inches + assert cell.width == expected_width, "got %s" % cell.width.inches -@then('the width of each column is {inches} inches') +@then("the width of each column is {inches} inches") def then_the_width_of_each_column_is_inches(context, inches): table = context.table_ expected_width = Inches(float(inches)) for column in table.columns: - assert column.width == expected_width, 'got %s' % column.width.inches + assert column.width == expected_width, "got %s" % column.width.inches diff --git a/features/steps/tabstops.py b/features/steps/tabstops.py index 4a6b442e0..280f655a6 100644 --- a/features/steps/tabstops.py +++ b/features/steps/tabstops.py @@ -140,4 +140,4 @@ def then_the_removed_tab_stop_is_no_longer_present_in_tab_stops(context): def then_the_tab_stops_are_sequenced_in_position_order(context): tab_stops = context.tab_stops for idx in range(len(tab_stops) - 1): - assert tab_stops[idx].position < tab_stops[idx+1].position + assert tab_stops[idx].position < tab_stops[idx + 1].position diff --git a/features/steps/test_files/bmk-bookmarks.docx b/features/steps/test_files/bmk-bookmarks.docx new file mode 100644 index 000000000..1fa4fe729 Binary files /dev/null and b/features/steps/test_files/bmk-bookmarks.docx differ diff --git a/features/tbl-cell-props.feature b/features/tbl-cell.feature similarity index 70% rename from features/tbl-cell-props.feature rename to features/tbl-cell.feature index 609d2f442..a06b960b7 100644 --- a/features/tbl-cell-props.feature +++ b/features/tbl-cell.feature @@ -1,7 +1,20 @@ -Feature: Get and set table cell properties +Feature: _Cell properties and methods In order to format a table cell to my requirements As a developer using python-docx - I need a way to get and set the properties of a table cell + I need properties and methods on a _Cell object + + + Scenario: _Cell.start_bookmark() + Given a _Cell object as cell + When I assign bookmark = cell.start_bookmark("Target") + Then bookmark.name == "Target" + And bookmark.id is an int + + + Scenario: _Cell.text setter + Given a _Cell object as cell + When I assign a string to the cell text attribute + Then the cell contains the string I assigned Scenario Outline: Get _Cell.vertical_alignment @@ -47,3 +60,11 @@ Feature: Get and set table cell properties | width-setting | new-setting | reported-width | | no explicit setting | 1 inch | 1 inch | | 2 inches | 1 inch | 1 inch | + + + Scenario: Add a table into a table cell + Given a _Cell object as cell + When I add a 2 x 2 table into the first cell + Then cell.tables[0] is a 2 x 2 table + And the width of each column is 1.5375 inches + And the width of each cell is 1.5375 inches diff --git a/tests/image/test_tiff.py b/tests/image/test_tiff.py index 277df5f21..6e7d6fa32 100644 --- a/tests/image/test_tiff.py +++ b/tests/image/test_tiff.py @@ -35,7 +35,6 @@ class DescribeTiff(object): - def it_can_construct_from_a_tiff_stream( self, stream_, _TiffParser_, tiff_parser_, Tiff__init_ ): @@ -60,7 +59,7 @@ def it_knows_its_content_type(self): def it_knows_its_default_ext(self): tiff = Tiff(None, None, None, None) - assert tiff.default_ext == 'tiff' + assert tiff.default_ext == "tiff" # fixtures ------------------------------------------------------- @@ -70,7 +69,7 @@ def Tiff__init_(self, request): @pytest.fixture def _TiffParser_(self, request, tiff_parser_): - _TiffParser_ = class_mock(request, 'docx.image.tiff._TiffParser') + _TiffParser_ = class_mock(request, "docx.image.tiff._TiffParser") _TiffParser_.parse.return_value = tiff_parser_ return _TiffParser_ @@ -84,7 +83,6 @@ def stream_(self, request): class Describe_TiffParser(object): - def it_can_parse_the_properties_from_a_tiff_stream( self, stream_, @@ -98,9 +96,7 @@ def it_can_parse_the_properties_from_a_tiff_stream( tiff_parser = _TiffParser.parse(stream_) _make_stream_reader_.assert_called_once_with(stream_) - _IfdEntries_.from_stream.assert_called_once_with( - stream_rdr_, ifd0_offset_ - ) + _IfdEntries_.from_stream.assert_called_once_with(stream_rdr_, ifd0_offset_) _TiffParser__init_.assert_called_once_with(ANY, ifd_entries_) assert isinstance(tiff_parser, _TiffParser) @@ -112,10 +108,7 @@ def it_makes_a_stream_reader_to_help_parse(self, mk_stream_rdr_fixture): def it_knows_image_width_and_height_after_parsing(self): px_width, px_height = 42, 24 - entries = { - TIFF_TAG.IMAGE_WIDTH: px_width, - TIFF_TAG.IMAGE_LENGTH: px_height, - } + entries = {TIFF_TAG.IMAGE_WIDTH: px_width, TIFF_TAG.IMAGE_LENGTH: px_height} ifd_entries = _IfdEntries(entries) tiff_parser = _TiffParser(ifd_entries) assert tiff_parser.px_width == px_width @@ -128,13 +121,15 @@ def it_knows_the_horz_and_vert_dpi_after_parsing(self, dpi_fixture): # fixtures ------------------------------------------------------- - @pytest.fixture(params=[ - (1, 150, 240, 72, 72), - (2, 42, 24, 42, 24), - (3, 100, 200, 254, 508), - (2, None, None, 72, 72), - (None, 96, 100, 96, 100), - ]) + @pytest.fixture( + params=[ + (1, 150, 240, 72, 72), + (2, 42, 24, 42, 24), + (3, 100, 200, 254, 508), + (2, None, None, 72, 72), + (None, 96, 100, 96, 100), + ] + ) def dpi_fixture(self, request): resolution_unit, x_resolution, y_resolution = request.param[:3] expected_horz_dpi, expected_vert_dpi = request.param[3:] @@ -152,7 +147,7 @@ def dpi_fixture(self, request): @pytest.fixture def _IfdEntries_(self, request, ifd_entries_): - _IfdEntries_ = class_mock(request, 'docx.image.tiff._IfdEntries') + _IfdEntries_ = class_mock(request, "docx.image.tiff._IfdEntries") _IfdEntries_.from_stream.return_value = ifd_entries_ return _IfdEntries_ @@ -169,15 +164,12 @@ def _make_stream_reader_(self, request, stream_rdr_): return method_mock( request, _TiffParser, - '_make_stream_reader', + "_make_stream_reader", autospec=False, - return_value=stream_rdr_ + return_value=stream_rdr_, ) - @pytest.fixture(params=[ - (b'MM\x00*', BIG_ENDIAN), - (b'II*\x00', LITTLE_ENDIAN), - ]) + @pytest.fixture(params=[(b"MM\x00*", BIG_ENDIAN), (b"II*\x00", LITTLE_ENDIAN)]) def mk_stream_rdr_fixture(self, request, StreamReader_, stream_rdr_): bytes_, endian = request.param stream = BytesIO(bytes_) @@ -190,7 +182,7 @@ def stream_(self, request): @pytest.fixture def StreamReader_(self, request, stream_rdr_): return class_mock( - request, 'docx.image.tiff.StreamReader', return_value=stream_rdr_ + request, "docx.image.tiff.StreamReader", return_value=stream_rdr_ ) @pytest.fixture @@ -205,7 +197,6 @@ def _TiffParser__init_(self, request): class Describe_IfdEntries(object): - def it_can_construct_from_a_stream_and_offset( self, stream_, @@ -226,7 +217,7 @@ def it_can_construct_from_a_stream_and_offset( assert isinstance(ifd_entries, _IfdEntries) def it_has_basic_mapping_semantics(self): - key, value = 1, 'foobar' + key, value = 1, "foobar" entries = {key: value} ifd_entries = _IfdEntries(entries) assert key in ifd_entries @@ -249,7 +240,7 @@ def _IfdEntries__init_(self, request): @pytest.fixture def _IfdParser_(self, request, ifd_parser_): return class_mock( - request, 'docx.image.tiff._IfdParser', return_value=ifd_parser_ + request, "docx.image.tiff._IfdParser", return_value=ifd_parser_ ) @pytest.fixture @@ -266,11 +257,14 @@ def stream_(self, request): class Describe_IfdParser(object): - - def it_can_iterate_through_the_directory_entries_in_an_IFD( - self, iter_fixture): - (ifd_parser, _IfdEntryFactory_, stream_rdr, offsets, - expected_entries) = iter_fixture + def it_can_iterate_through_the_directory_entries_in_an_IFD(self, iter_fixture): + ( + ifd_parser, + _IfdEntryFactory_, + stream_rdr, + offsets, + expected_entries, + ) = iter_fixture entries = [e for e in ifd_parser.iter_entries()] assert _IfdEntryFactory_.call_args_list == [ call(stream_rdr, offsets[0]), @@ -291,24 +285,21 @@ def ifd_entry_2_(self, request): @pytest.fixture def _IfdEntryFactory_(self, request, ifd_entry_, ifd_entry_2_): return function_mock( - request, 'docx.image.tiff._IfdEntryFactory', - side_effect=[ifd_entry_, ifd_entry_2_] + request, + "docx.image.tiff._IfdEntryFactory", + side_effect=[ifd_entry_, ifd_entry_2_], ) @pytest.fixture def iter_fixture(self, _IfdEntryFactory_, ifd_entry_, ifd_entry_2_): - stream_rdr = StreamReader(BytesIO(b'\x00\x02'), BIG_ENDIAN) + stream_rdr = StreamReader(BytesIO(b"\x00\x02"), BIG_ENDIAN) offsets = [2, 14] ifd_parser = _IfdParser(stream_rdr, offset=0) expected_entries = [ifd_entry_, ifd_entry_2_] - return ( - ifd_parser, _IfdEntryFactory_, stream_rdr, offsets, - expected_entries - ) + return (ifd_parser, _IfdEntryFactory_, stream_rdr, offsets, expected_entries) class Describe_IfdEntryFactory(object): - def it_constructs_the_right_class_for_a_given_ifd_entry(self, fixture): stream_rdr, offset, entry_cls_, ifd_entry_ = fixture ifd_entry = _IfdEntryFactory(stream_rdr, offset) @@ -317,25 +308,34 @@ def it_constructs_the_right_class_for_a_given_ifd_entry(self, fixture): # fixtures ------------------------------------------------------- - @pytest.fixture(params=[ - (b'\x66\x66\x00\x01', 'BYTE'), - (b'\x66\x66\x00\x02', 'ASCII'), - (b'\x66\x66\x00\x03', 'SHORT'), - (b'\x66\x66\x00\x04', 'LONG'), - (b'\x66\x66\x00\x05', 'RATIONAL'), - (b'\x66\x66\x00\x06', 'CUSTOM'), - ]) + @pytest.fixture( + params=[ + (b"\x66\x66\x00\x01", "BYTE"), + (b"\x66\x66\x00\x02", "ASCII"), + (b"\x66\x66\x00\x03", "SHORT"), + (b"\x66\x66\x00\x04", "LONG"), + (b"\x66\x66\x00\x05", "RATIONAL"), + (b"\x66\x66\x00\x06", "CUSTOM"), + ] + ) def fixture( - self, request, ifd_entry_, _IfdEntry_, _AsciiIfdEntry_, - _ShortIfdEntry_, _LongIfdEntry_, _RationalIfdEntry_): + self, + request, + ifd_entry_, + _IfdEntry_, + _AsciiIfdEntry_, + _ShortIfdEntry_, + _LongIfdEntry_, + _RationalIfdEntry_, + ): bytes_, entry_type = request.param entry_cls_ = { - 'BYTE': _IfdEntry_, - 'ASCII': _AsciiIfdEntry_, - 'SHORT': _ShortIfdEntry_, - 'LONG': _LongIfdEntry_, - 'RATIONAL': _RationalIfdEntry_, - 'CUSTOM': _IfdEntry_, + "BYTE": _IfdEntry_, + "ASCII": _AsciiIfdEntry_, + "SHORT": _ShortIfdEntry_, + "LONG": _LongIfdEntry_, + "RATIONAL": _RationalIfdEntry_, + "CUSTOM": _IfdEntry_, }[entry_type] stream_rdr = StreamReader(BytesIO(bytes_), BIG_ENDIAN) offset = 0 @@ -347,35 +347,31 @@ def ifd_entry_(self, request): @pytest.fixture def _IfdEntry_(self, request, ifd_entry_): - _IfdEntry_ = class_mock(request, 'docx.image.tiff._IfdEntry') + _IfdEntry_ = class_mock(request, "docx.image.tiff._IfdEntry") _IfdEntry_.from_stream.return_value = ifd_entry_ return _IfdEntry_ @pytest.fixture def _AsciiIfdEntry_(self, request, ifd_entry_): - _AsciiIfdEntry_ = class_mock( - request, 'docx.image.tiff._AsciiIfdEntry') + _AsciiIfdEntry_ = class_mock(request, "docx.image.tiff._AsciiIfdEntry") _AsciiIfdEntry_.from_stream.return_value = ifd_entry_ return _AsciiIfdEntry_ @pytest.fixture def _ShortIfdEntry_(self, request, ifd_entry_): - _ShortIfdEntry_ = class_mock( - request, 'docx.image.tiff._ShortIfdEntry') + _ShortIfdEntry_ = class_mock(request, "docx.image.tiff._ShortIfdEntry") _ShortIfdEntry_.from_stream.return_value = ifd_entry_ return _ShortIfdEntry_ @pytest.fixture def _LongIfdEntry_(self, request, ifd_entry_): - _LongIfdEntry_ = class_mock( - request, 'docx.image.tiff._LongIfdEntry') + _LongIfdEntry_ = class_mock(request, "docx.image.tiff._LongIfdEntry") _LongIfdEntry_.from_stream.return_value = ifd_entry_ return _LongIfdEntry_ @pytest.fixture def _RationalIfdEntry_(self, request, ifd_entry_): - _RationalIfdEntry_ = class_mock( - request, 'docx.image.tiff._RationalIfdEntry') + _RationalIfdEntry_ = class_mock(request, "docx.image.tiff._RationalIfdEntry") _RationalIfdEntry_.from_stream.return_value = ifd_entry_ return _RationalIfdEntry_ @@ -385,11 +381,10 @@ def offset_(self, request): class Describe_IfdEntry(object): - def it_can_construct_from_a_stream_and_offset( self, _parse_value_, _IfdEntry__init_, value_ ): - bytes_ = b'\x00\x01\x66\x66\x00\x00\x00\x02\x00\x00\x00\x03' + bytes_ = b"\x00\x01\x66\x66\x00\x00\x00\x02\x00\x00\x00\x03" stream_rdr = StreamReader(BytesIO(bytes_), BIG_ENDIAN) offset, tag_code, value_count, value_offset = 0, 1, 2, 3 _parse_value_.return_value = value_ @@ -415,7 +410,7 @@ def _IfdEntry__init_(self, request): @pytest.fixture def _parse_value_(self, request): - return method_mock(request, _IfdEntry, '_parse_value', autospec=False) + return method_mock(request, _IfdEntry, "_parse_value", autospec=False) @pytest.fixture def value_(self, request): @@ -423,36 +418,32 @@ def value_(self, request): class Describe_AsciiIfdEntry(object): - def it_can_parse_an_ascii_string_IFD_entry(self): - bytes_ = b'foobar\x00' + bytes_ = b"foobar\x00" stream_rdr = StreamReader(BytesIO(bytes_), BIG_ENDIAN) val = _AsciiIfdEntry._parse_value(stream_rdr, None, 7, 0) - assert val == 'foobar' + assert val == "foobar" class Describe_ShortIfdEntry(object): - def it_can_parse_a_short_int_IFD_entry(self): - bytes_ = b'foobaroo\x00\x2A' + bytes_ = b"foobaroo\x00\x2A" stream_rdr = StreamReader(BytesIO(bytes_), BIG_ENDIAN) val = _ShortIfdEntry._parse_value(stream_rdr, 0, 1, None) assert val == 42 class Describe_LongIfdEntry(object): - def it_can_parse_a_long_int_IFD_entry(self): - bytes_ = b'foobaroo\x00\x00\x00\x2A' + bytes_ = b"foobaroo\x00\x00\x00\x2A" stream_rdr = StreamReader(BytesIO(bytes_), BIG_ENDIAN) val = _LongIfdEntry._parse_value(stream_rdr, 0, 1, None) assert val == 42 class Describe_RationalIfdEntry(object): - def it_can_parse_a_rational_IFD_entry(self): - bytes_ = b'\x00\x00\x00\x2A\x00\x00\x00\x54' + bytes_ = b"\x00\x00\x00\x2A\x00\x00\x00\x54" stream_rdr = StreamReader(BytesIO(bytes_), BIG_ENDIAN) val = _RationalIfdEntry._parse_value(stream_rdr, None, 1, 0) assert val == 0.5 diff --git a/tests/opc/test_part.py b/tests/opc/test_part.py index cc60beaae..5fe0a0b99 100644 --- a/tests/opc/test_part.py +++ b/tests/opc/test_part.py @@ -21,11 +21,12 @@ initializer_mock, instance_mock, loose_mock, - Mock, + property_mock, ) class DescribePart(object): + """Unit-test suite for `docx.opc.part.Part` object.""" def it_can_be_constructed_by_PartFactory( self, partname_, content_type_, blob_, package_, __init_ @@ -71,7 +72,7 @@ def blob_fixture(self, blob_): @pytest.fixture def content_type_fixture(self): - content_type = 'content/type' + content_type = "content/type" part = Part(None, content_type, None, None) return part, content_type @@ -87,14 +88,14 @@ def part(self): @pytest.fixture def partname_get_fixture(self): - partname = PackURI('/part/name') + partname = PackURI("/part/name") part = Part(partname, None, None, None) return part, partname @pytest.fixture def partname_set_fixture(self): - old_partname = PackURI('/old/part/name') - new_partname = PackURI('/new/part/name') + old_partname = PackURI("/old/part/name") + new_partname = PackURI("/new/part/name") part = Part(old_partname, None, None, None) return part, new_partname @@ -122,7 +123,6 @@ def partname_(self, request): class DescribePartRelationshipManagementInterface(object): - def it_provides_access_to_its_relationships(self, rels_fixture): part, Relationships_, partname_, rels_ = rels_fixture rels = part.rels @@ -132,19 +132,15 @@ def it_provides_access_to_its_relationships(self, rels_fixture): def it_can_load_a_relationship(self, load_rel_fixture): part, rels_, reltype_, target_, rId_ = load_rel_fixture part.load_rel(reltype_, target_, rId_) - rels_.add_relationship.assert_called_once_with( - reltype_, target_, rId_, False - ) + rels_.add_relationship.assert_called_once_with(reltype_, target_, rId_, False) - def it_can_establish_a_relationship_to_another_part( - self, relate_to_part_fixture): + def it_can_establish_a_relationship_to_another_part(self, relate_to_part_fixture): part, target_, reltype_, rId_ = relate_to_part_fixture rId = part.relate_to(target_, reltype_) part.rels.get_or_add.assert_called_once_with(reltype_, target_) assert rId is rId_ - def it_can_establish_an_external_relationship( - self, relate_to_url_fixture): + def it_can_establish_an_external_relationship(self, relate_to_url_fixture): part, url_, reltype_, rId_ = relate_to_url_fixture rId = part.relate_to(url_, reltype_, is_external=True) part.rels.get_or_add_ext_rel.assert_called_once_with(reltype_, url_) @@ -168,22 +164,38 @@ def it_can_find_a_related_part_by_rId(self, related_parts_fixture): part, related_parts_ = related_parts_fixture assert part.related_parts is related_parts_ - def it_can_find_the_uri_of_an_external_relationship( - self, target_ref_fixture): + def it_can_find_the_uri_of_an_external_relationship(self, target_ref_fixture): part, rId_, url_ = target_ref_fixture url = part.target_ref(rId_) assert url == url_ + def it_can_iterate_parts_related_by_reltypes(self, rel_types_fixture, rels_prop_): + rels_, reltypes, expected_parts = rel_types_fixture + rels_prop_.return_value = rels_ + part = Part(None, None) + + parts = set(part.iter_parts_related_by(reltypes)) + + assert parts == expected_parts + # fixtures --------------------------------------------- - @pytest.fixture(params=[ - ('w:p', True), - ('w:p/r:a{r:id=rId42}', True), - ('w:p/r:a{r:id=rId42}/r:b{r:id=rId42}', False), - ]) + def docx_rel(self, request, rtype, doc_part, rId_, url_): + rel_ = instance_mock(request, _Relationship, rId=rId_, target_ref=url_) + rel_.reltype = rtype + rel_.target_part = doc_part + return rel_ + + @pytest.fixture( + params=[ + ("w:p", True), + ("w:p/r:a{r:id=rId42}", True), + ("w:p/r:a{r:id=rId42}/r:b{r:id=rId42}", False), + ] + ) def drop_rel_fixture(self, request, part): part_cxml, rel_should_be_dropped = request.param - rId = 'rId42' + rId = "rId42" part._element = element(part_cxml) part._rels = {rId: None} return part, rId, rel_should_be_dropped @@ -193,16 +205,47 @@ def load_rel_fixture(self, part, rels_, reltype_, part_, rId_): part._rels = rels_ return part, rels_, reltype_, part_, rId_ + @pytest.fixture( + params=( + ((), ()), + (("foo",), (0, 2)), + (("bar",), (1,)), + (("foo", "bar"), (0, 1, 2)), + (("foo", "bar", "baz"), (0, 1, 2)), + (("boo", "bar", "faz"), (1,)), + ) + ) + def rel_types_fixture(self, request): + # ---rels_ has three relationships, of type "foo", "bar", and "foo" respectively + # ---and pointing to part_0, 1, and 2 respectively + reltypes, expected_part_idxs = request.param + parts_ = tuple( + instance_mock(request, Part, name="part_%d" % idx) for idx in range(3) + ) + relationships_ = tuple( + instance_mock( + request, + _Relationship, + name="rel_%d" % idx, + reltype=reltype, + target_part=parts_[idx], + ) + for idx, reltype in enumerate(("foo", "bar", "foo")) + ) + rels_ = dict(("rId%d" % (idx + 1), relationships_[idx]) for idx in range(3)) + expected_parts = set( + parts_[idx] for idx in range(3) if idx in expected_part_idxs + ) + return rels_, reltypes, expected_parts + @pytest.fixture - def relate_to_part_fixture( - self, request, part, reltype_, part_, rels_, rId_): + def relate_to_part_fixture(self, request, part, reltype_, part_, rels_, rId_): part._rels = rels_ target_ = part_ return part, target_, reltype_, rId_ @pytest.fixture - def relate_to_url_fixture( - self, request, part, rels_, url_, reltype_, rId_): + def relate_to_url_fixture(self, request, part, rels_, url_, reltype_, rId_): part._rels = rels_ return part, url_, reltype_, rId_ @@ -242,15 +285,11 @@ def partname_(self, request): @pytest.fixture def Relationships_(self, request, rels_): - return class_mock( - request, 'docx.opc.part.Relationships', return_value=rels_ - ) + return class_mock(request, "docx.opc.part.Relationships", return_value=rels_) @pytest.fixture def rel_(self, request, rId_, url_): - return instance_mock( - request, _Relationship, rId=rId_, target_ref=url_ - ) + return instance_mock(request, _Relationship, rId=rId_, target_ref=url_) @pytest.fixture def rels_(self, request, part_, rel_, rId_, related_parts_): @@ -261,6 +300,10 @@ def rels_(self, request, part_, rel_, rId_, related_parts_): rels_.related_parts = related_parts_ return rels_ + @pytest.fixture + def rels_prop_(self, request): + return property_mock(request, Part, "rels") + @pytest.fixture def related_parts_(self, request): return instance_mock(request, dict) @@ -279,12 +322,14 @@ def url_(self, request): class DescribePartFactory(object): - - def it_constructs_part_from_selector_if_defined( - self, cls_selector_fixture): + def it_constructs_part_from_selector_if_defined(self, cls_selector_fixture): # fixture ---------------------- - (cls_selector_fn_, part_load_params, CustomPartClass_, - part_of_custom_type_) = cls_selector_fixture + ( + cls_selector_fn_, + part_load_params, + CustomPartClass_, + part_of_custom_type_, + ) = cls_selector_fixture partname, content_type, reltype, blob, package = part_load_params # exercise --------------------- PartFactory.part_class_selector = cls_selector_fn_ @@ -297,7 +342,8 @@ def it_constructs_part_from_selector_if_defined( assert part is part_of_custom_type_ def it_constructs_custom_part_type_for_registered_content_types( - self, part_args_, CustomPartClass_, part_of_custom_type_): + self, part_args_, CustomPartClass_, part_of_custom_type_ + ): # fixture ---------------------- partname, content_type, reltype, package, blob = part_args_ # exercise --------------------- @@ -310,7 +356,8 @@ def it_constructs_custom_part_type_for_registered_content_types( assert part is part_of_custom_type_ def it_constructs_part_using_default_class_when_no_custom_registered( - self, part_args_2_, DefaultPartClass_, part_of_default_type_): + self, part_args_2_, DefaultPartClass_, part_of_default_type_ + ): partname, content_type, reltype, blob, package = part_args_2_ part = PartFactory(partname, content_type, reltype, blob, package) DefaultPartClass_.load.assert_called_once_with( @@ -331,21 +378,29 @@ def blob_2_(self, request): @pytest.fixture def cls_method_fn_(self, request, cls_selector_fn_): return function_mock( - request, 'docx.opc.part.cls_method_fn', - return_value=cls_selector_fn_ + request, "docx.opc.part.cls_method_fn", return_value=cls_selector_fn_ ) @pytest.fixture def cls_selector_fixture( - self, request, cls_selector_fn_, cls_method_fn_, part_load_params, - CustomPartClass_, part_of_custom_type_): + self, + request, + cls_selector_fn_, + cls_method_fn_, + part_load_params, + CustomPartClass_, + part_of_custom_type_, + ): def reset_part_class_selector(): PartFactory.part_class_selector = original_part_class_selector + original_part_class_selector = PartFactory.part_class_selector request.addfinalizer(reset_part_class_selector) return ( - cls_selector_fn_, part_load_params, CustomPartClass_, - part_of_custom_type_ + cls_selector_fn_, + part_load_params, + CustomPartClass_, + part_of_custom_type_, ) @pytest.fixture @@ -355,7 +410,7 @@ def cls_selector_fn_(self, request, CustomPartClass_): cls_selector_fn_.return_value = CustomPartClass_ # Python 2 version cls_selector_fn_.__func__ = loose_mock( - request, name='__func__', return_value=cls_selector_fn_ + request, name="__func__", return_value=cls_selector_fn_ ) return cls_selector_fn_ @@ -369,15 +424,13 @@ def content_type_2_(self, request): @pytest.fixture def CustomPartClass_(self, request, part_of_custom_type_): - CustomPartClass_ = Mock(name='CustomPartClass', spec=Part) + CustomPartClass_ = instance_mock(request, Part, name="CustomPartClass") CustomPartClass_.load.return_value = part_of_custom_type_ return CustomPartClass_ @pytest.fixture def DefaultPartClass_(self, request, part_of_default_type_): - DefaultPartClass_ = cls_attr_mock( - request, PartFactory, 'default_part_type' - ) + DefaultPartClass_ = cls_attr_mock(request, PartFactory, "default_part_type") DefaultPartClass_.load.return_value = part_of_default_type_ return DefaultPartClass_ @@ -390,8 +443,7 @@ def package_2_(self, request): return instance_mock(request, OpcPackage) @pytest.fixture - def part_load_params( - self, partname_, content_type_, reltype_, blob_, package_): + def part_load_params(self, partname_, content_type_, reltype_, blob_, package_): return partname_, content_type_, reltype_, blob_, package_ @pytest.fixture @@ -411,15 +463,13 @@ def partname_2_(self, request): return instance_mock(request, PackURI) @pytest.fixture - def part_args_( - self, request, partname_, content_type_, reltype_, package_, - blob_): + def part_args_(self, request, partname_, content_type_, reltype_, package_, blob_): return partname_, content_type_, reltype_, blob_, package_ @pytest.fixture def part_args_2_( - self, request, partname_2_, content_type_2_, reltype_2_, - package_2_, blob_2_): + self, request, partname_2_, content_type_2_, reltype_2_, package_2_, blob_2_ + ): return partname_2_, content_type_2_, reltype_2_, blob_2_, package_2_ @pytest.fixture @@ -432,6 +482,7 @@ def reltype_2_(self, request): class DescribeXmlPart(object): + """Unit-test suite for `docx.opc.part.XmlPart` object.""" def it_can_be_constructed_by_PartFactory( self, partname_, content_type_, blob_, package_, element_, parse_xml_, __init_ @@ -489,9 +540,7 @@ def package_(self, request): @pytest.fixture def parse_xml_(self, request, element_): - return function_mock( - request, 'docx.opc.part.parse_xml', return_value=element_ - ) + return function_mock(request, "docx.opc.part.parse_xml", return_value=element_) @pytest.fixture def partname_(self, request): @@ -499,6 +548,4 @@ def partname_(self, request): @pytest.fixture def serialize_part_xml_(self, request): - return function_mock( - request, 'docx.opc.part.serialize_part_xml' - ) + return function_mock(request, "docx.opc.part.serialize_part_xml") diff --git a/tests/opc/test_pkgreader.py b/tests/opc/test_pkgreader.py index 96885efcb..7dfc9a363 100644 --- a/tests/opc/test_pkgreader.py +++ b/tests/opc/test_pkgreader.py @@ -250,7 +250,7 @@ def sparts_( self, request, partnames_, content_types_, reltypes_, blobs_): sparts_ = [] for idx in range(2): - name = 'spart_%s' % (('%d_' % (idx+1)) if idx else '') + name = 'spart_%s' % (('%d_' % (idx + 1)) if idx else '') spart_ = instance_mock( request, _SerializedPart, name=name, partname=partnames_[idx], content_type=content_types_[idx], diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index d6f0e7731..427abea50 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -6,6 +6,7 @@ import pytest +from docx.bookmark import Bookmarks from docx.enum.style import WD_STYLE_TYPE from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.coreprops import CoreProperties @@ -24,6 +25,7 @@ class DescribeDocumentPart(object): + """Unit-test suite for `docx.parts.document.DocumentPart`.""" def it_can_add_a_footer_part(self, package_, FooterPart_, footer_part_, relate_to_): FooterPart_.new.return_value = footer_part_ @@ -49,6 +51,15 @@ def it_can_add_a_header_part(self, package_, HeaderPart_, header_part_, relate_t assert header_part is header_part_ assert rId == "rId7" + def it_provides_access_to_the_package_bookmarks(self, Bookmarks_, bookmarks_): + Bookmarks_.return_value = bookmarks_ + document_part = DocumentPart(None, None, None, None) + + bookmarks = document_part.bookmarks + + Bookmarks_.assert_called_once_with(document_part) + assert bookmarks is bookmarks_ + def it_can_drop_a_specified_header_part(self, drop_rel_): document_part = DocumentPart(None, None, None, None) @@ -80,6 +91,30 @@ def it_provides_access_to_a_header_part_by_rId( related_parts_.__getitem__.assert_called_once_with("rId11") assert header_part is header_part_ + def it_provides_access_to_the_inline_shapes_in_the_document( + self, inline_shapes_fixture + ): + document, InlineShapes_, body_elm = inline_shapes_fixture + + inline_shapes = document.inline_shapes + + InlineShapes_.assert_called_once_with(body_elm, document) + assert inline_shapes is InlineShapes_.return_value + + def it_can_iterate_the_story_parts( + self, iter_parts_related_by_, header_part_, footer_part_ + ): + iter_parts_related_by_.return_value = iter((header_part_, footer_part_)) + document_part = DocumentPart(None, None, None, None) + + story_parts = document_part.iter_story_parts() + + iter_parts_related_by_.assert_called_once_with( + document_part, + {RT.COMMENTS, RT.ENDNOTES, RT.FOOTER, RT.FOOTNOTES, RT.HEADER}, + ) + assert list(story_parts) == [document_part, header_part_, footer_part_] + def it_can_save_the_package_to_a_file(self, save_fixture): document, file_ = save_fixture document.save(file_) @@ -100,13 +135,6 @@ def it_provides_access_to_its_core_properties(self, core_props_fixture): core_properties = document_part.core_properties assert core_properties is core_properties_ - def it_provides_access_to_the_inline_shapes_in_the_document( - self, inline_shapes_fixture): - document, InlineShapes_, body_elm = inline_shapes_fixture - inline_shapes = document.inline_shapes - InlineShapes_.assert_called_once_with(body_elm, document) - assert inline_shapes is InlineShapes_.return_value - def it_provides_access_to_the_numbering_part( self, part_related_by_, numbering_part_ ): @@ -209,10 +237,7 @@ def core_props_fixture(self, package_, core_properties_): @pytest.fixture def inline_shapes_fixture(self, request, InlineShapes_): - document_elm = ( - a_document().with_nsdecls().with_child( - a_body()) - ).element + document_elm = (a_document().with_nsdecls().with_child(a_body())).element body_elm = document_elm[0] document = DocumentPart(None, None, document_elm, None) return document, InlineShapes_, body_elm @@ -220,12 +245,11 @@ def inline_shapes_fixture(self, request, InlineShapes_): @pytest.fixture def save_fixture(self, package_): document_part = DocumentPart(None, None, None, package_) - file_ = 'foobar.docx' + file_ = "foobar.docx" return document_part, file_ @pytest.fixture - def settings_fixture(self, _settings_part_prop_, settings_part_, - settings_): + def settings_fixture(self, _settings_part_prop_, settings_part_, settings_): document_part = DocumentPart(None, None, None, None) _settings_part_prop_.return_value = settings_part_ settings_part_.settings = settings_ @@ -240,6 +264,14 @@ def styles_fixture(self, _styles_part_prop_, styles_part_, styles_): # fixture components --------------------------------------------- + @pytest.fixture + def Bookmarks_(self, request): + return class_mock(request, "docx.parts.document.Bookmarks") + + @pytest.fixture + def bookmarks_(self, request): + return instance_mock(request, Bookmarks) + @pytest.fixture def core_properties_(self, request): return instance_mock(request, CoreProperties) @@ -250,7 +282,7 @@ def drop_rel_(self, request): @pytest.fixture def FooterPart_(self, request): - return class_mock(request, 'docx.parts.document.FooterPart') + return class_mock(request, "docx.parts.document.FooterPart") @pytest.fixture def footer_part_(self, request): @@ -258,7 +290,7 @@ def footer_part_(self, request): @pytest.fixture def HeaderPart_(self, request): - return class_mock(request, 'docx.parts.document.HeaderPart') + return class_mock(request, "docx.parts.document.HeaderPart") @pytest.fixture def header_part_(self, request): @@ -266,11 +298,15 @@ def header_part_(self, request): @pytest.fixture def InlineShapes_(self, request): - return class_mock(request, 'docx.parts.document.InlineShapes') + return class_mock(request, "docx.parts.document.InlineShapes") + + @pytest.fixture + def iter_parts_related_by_(self, request): + return method_mock(request, DocumentPart, "iter_parts_related_by") @pytest.fixture def NumberingPart_(self, request): - return class_mock(request, 'docx.parts.document.NumberingPart') + return class_mock(request, "docx.parts.document.NumberingPart") @pytest.fixture def numbering_part_(self, request): @@ -282,11 +318,11 @@ def package_(self, request): @pytest.fixture def part_related_by_(self, request): - return method_mock(request, DocumentPart, 'part_related_by') + return method_mock(request, DocumentPart, "part_related_by") @pytest.fixture def relate_to_(self, request): - return method_mock(request, DocumentPart, 'relate_to') + return method_mock(request, DocumentPart, "relate_to") @pytest.fixture def related_parts_(self, request): @@ -294,11 +330,11 @@ def related_parts_(self, request): @pytest.fixture def related_parts_prop_(self, request): - return property_mock(request, DocumentPart, 'related_parts') + return property_mock(request, DocumentPart, "related_parts") @pytest.fixture def SettingsPart_(self, request): - return class_mock(request, 'docx.parts.document.SettingsPart') + return class_mock(request, "docx.parts.document.SettingsPart") @pytest.fixture def settings_(self, request): @@ -310,7 +346,7 @@ def settings_part_(self, request): @pytest.fixture def _settings_part_prop_(self, request): - return property_mock(request, DocumentPart, '_settings_part') + return property_mock(request, DocumentPart, "_settings_part") @pytest.fixture def style_(self, request): @@ -322,7 +358,7 @@ def styles_(self, request): @pytest.fixture def StylesPart_(self, request): - return class_mock(request, 'docx.parts.document.StylesPart') + return class_mock(request, "docx.parts.document.StylesPart") @pytest.fixture def styles_part_(self, request): @@ -330,8 +366,8 @@ def styles_part_(self, request): @pytest.fixture def styles_prop_(self, request): - return property_mock(request, DocumentPart, 'styles') + return property_mock(request, DocumentPart, "styles") @pytest.fixture def _styles_part_prop_(self, request): - return property_mock(request, DocumentPart, '_styles_part') + return property_mock(request, DocumentPart, "_styles_part") diff --git a/tests/parts/test_endnotes.py b/tests/parts/test_endnotes.py new file mode 100644 index 000000000..c617ec089 --- /dev/null +++ b/tests/parts/test_endnotes.py @@ -0,0 +1,46 @@ +# encoding: utf-8 + +"""Unit test suite for the docx.parts.endnotes module""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +import pytest + +from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT +from docx.opc.part import PartFactory +from docx.package import Package +from docx.parts.endnotes import EndnotesPart + +from ..unitutil.mock import instance_mock, method_mock + + +class DescribeEndnotesPart(object): + def it_is_used_by_loader_to_construct_endnotes_part( + self, package_, EndnotesPart_load_, endnotes_part_ + ): + partname = "endnotes.xml" + content_type = CT.WML_ENDNOTES + reltype = RT.ENDNOTES + blob = "" + EndnotesPart_load_.return_value = endnotes_part_ + + part = PartFactory(partname, content_type, reltype, blob, package_) + + EndnotesPart_load_.assert_called_once_with( + partname, content_type, blob, package_ + ) + assert part is endnotes_part_ + + # fixture components --------------------------------------------- + + @pytest.fixture + def EndnotesPart_load_(self, request): + return method_mock(request, EndnotesPart, "load", autospec=False) + + @pytest.fixture + def endnotes_part_(self, request): + return instance_mock(request, EndnotesPart) + + @pytest.fixture + def package_(self, request): + return instance_mock(request, Package) diff --git a/tests/parts/test_footnotes.py b/tests/parts/test_footnotes.py new file mode 100644 index 000000000..7d92dd07c --- /dev/null +++ b/tests/parts/test_footnotes.py @@ -0,0 +1,46 @@ +# encoding: utf-8 + +"""Unit test suite for the docx.parts.footnotes module""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +import pytest + +from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT +from docx.opc.part import PartFactory +from docx.package import Package +from docx.parts.footnotes import FootnotesPart + +from ..unitutil.mock import instance_mock, method_mock + + +class DescribeFootnotesPart(object): + def it_is_used_by_loader_to_construct_footnotes_part( + self, package_, FootnotesPart_load_, footnotes_part_ + ): + partname = "footnotes.xml" + content_type = CT.WML_FOOTNOTES + reltype = RT.FOOTNOTES + blob = "" + FootnotesPart_load_.return_value = footnotes_part_ + + part = PartFactory(partname, content_type, reltype, blob, package_) + + FootnotesPart_load_.assert_called_once_with( + partname, content_type, blob, package_ + ) + assert part is footnotes_part_ + + # fixture components --------------------------------------------- + + @pytest.fixture + def FootnotesPart_load_(self, request): + return method_mock(request, FootnotesPart, "load", autospec=False) + + @pytest.fixture + def footnotes_part_(self, request): + return instance_mock(request, FootnotesPart) + + @pytest.fixture + def package_(self, request): + return instance_mock(request, Package) diff --git a/tests/parts/test_story.py b/tests/parts/test_story.py index e42fc49ae..1baae22e6 100644 --- a/tests/parts/test_story.py +++ b/tests/parts/test_story.py @@ -6,6 +6,7 @@ import pytest +from docx.bookmark import Bookmarks from docx.enum.style import WD_STYLE_TYPE from docx.image.image import Image from docx.opc.constants import RELATIONSHIP_TYPE as RT @@ -21,6 +22,18 @@ class DescribeBaseStoryPart(object): + """Unit-test suite for `docx.parts.story.BaseStoryPart` object.""" + + def it_provides_access_to_the_package_bookmarks( + self, _document_part_prop_, document_part_, bookmarks_ + ): + document_part_.bookmarks = bookmarks_ + _document_part_prop_.return_value = document_part_ + story_part = BaseStoryPart(None, None, None, None) + + bookmarks = story_part.bookmarks + + assert bookmarks is bookmarks_ def it_can_get_or_add_an_image(self, package_, image_part_, image_, relate_to_): package_.get_or_add_image_part.return_value = image_part_ @@ -114,6 +127,10 @@ def next_id_fixture(self, request): # fixture components --------------------------------------------- + @pytest.fixture + def bookmarks_(self, request): + return instance_mock(request, Bookmarks) + @pytest.fixture def document_part_(self, request): return instance_mock(request, DocumentPart) diff --git a/tests/test_blkcntnr.py b/tests/test_blkcntnr.py index 5bc9bab3f..4fe21e43b 100644 --- a/tests/test_blkcntnr.py +++ b/tests/test_blkcntnr.py @@ -7,16 +7,27 @@ import pytest from docx.blkcntnr import BlockItemContainer +from docx.bookmark import _Bookmark, Bookmarks +from docx.parts.document import DocumentPart +from docx.parts.hdrftr import FooterPart, HeaderPart from docx.shared import Inches from docx.table import Table from docx.text.paragraph import Paragraph from .unitutil.cxml import element, xml from .unitutil.file import snippet_seq -from .unitutil.mock import call, instance_mock, method_mock +from .unitutil.mock import ( + ANY, + call, + class_mock, + instance_mock, + method_mock, + property_mock, +) class DescribeBlockItemContainer(object): + """Unit-test suite for `docx.blkcntr.BlockItemContainer` object.""" def it_can_add_a_paragraph(self, add_paragraph_fixture, _add_paragraph_): text, style, paragraph_, add_run_calls = add_paragraph_fixture @@ -32,16 +43,42 @@ def it_can_add_a_paragraph(self, add_paragraph_fixture, _add_paragraph_): def it_can_add_a_table(self, add_table_fixture): blkcntnr, rows, cols, width, expected_xml = add_table_fixture + table = blkcntnr.add_table(rows, cols, width) + assert isinstance(table, Table) assert table._element.xml == expected_xml assert table._parent is blkcntnr - def it_provides_access_to_the_paragraphs_it_contains( - self, paragraphs_fixture): - # test len(), iterable, and indexed access + def it_can_end_a_bookmark(self, end_bookmark_fixture, bookmark_): + blockContainer, bookmark_id, expected_xml = end_bookmark_fixture + bookmark_.close.return_value = bookmark_ + bookmark_.id = bookmark_id + bookmark_.is_closed = False + blkcntnr = BlockItemContainer(blockContainer, None) + + bookmark = blkcntnr.end_bookmark(bookmark_) + + bookmark_.close.assert_called_once_with( + blockContainer.xpath("w:bookmarkEnd")[-1] + ) + assert blkcntnr._element.xml == expected_xml + assert bookmark is bookmark_ + + def but_it_raises_when_bookmark_is_already_closed(self, bookmark_): + bookmark_.is_closed = True + blkcntnr = BlockItemContainer(None, None) + + with pytest.raises(ValueError) as e: + blkcntnr.end_bookmark(bookmark_) + assert "bookmark already closed" in str(e.value) + + def it_provides_access_to_the_paragraphs_it_contains(self, paragraphs_fixture): + # ---test len(), iterable, and indexed access--- blkcntnr, expected_count = paragraphs_fixture + paragraphs = blkcntnr.paragraphs + assert len(paragraphs) == expected_count count = 0 for idx, paragraph in enumerate(paragraphs): @@ -50,10 +87,44 @@ def it_provides_access_to_the_paragraphs_it_contains( count += 1 assert count == expected_count + def it_can_start_a_bookmark( + self, + start_bookmark_fixture, + _bookmarks_prop_, + bookmarks_, + _Bookmark_, + bookmark_, + ): + blockContainer, name, next_id, expected_xml = start_bookmark_fixture + bookmarks_.__contains__.return_value = False + bookmarks_.next_id = next_id + _bookmarks_prop_.return_value = bookmarks_ + _Bookmark_.return_value = bookmark_ + blkcntnr = BlockItemContainer(blockContainer, None) + + bookmark = blkcntnr.start_bookmark(name) + + _Bookmark_.assert_called_once_with((ANY, None)) + assert blkcntnr._element.xml == expected_xml + assert bookmark is bookmark_ + + def but_it_raises_KeyError_when_bookmark_name_already_exists( + self, _bookmarks_prop_, bookmarks_ + ): + bookmarks_.__contains__.return_value = True + _bookmarks_prop_.return_value = bookmarks_ + blkcntnr = BlockItemContainer(None, None) + + with pytest.raises(KeyError) as e: + blkcntnr.start_bookmark("X") + assert "Document already contains bookmark with name X" in str(e.value) + def it_provides_access_to_the_tables_it_contains(self, tables_fixture): - # test len(), iterable, and indexed access + # ---test len(), iterable, and indexed access--- blkcntnr, expected_count = tables_fixture + tables = blkcntnr.tables + assert len(tables) == expected_count count = 0 for idx, table in enumerate(tables): @@ -64,19 +135,28 @@ def it_provides_access_to_the_tables_it_contains(self, tables_fixture): def it_adds_a_paragraph_to_help(self, _add_paragraph_fixture): blkcntnr, expected_xml = _add_paragraph_fixture + new_paragraph = blkcntnr._add_paragraph() + assert isinstance(new_paragraph, Paragraph) assert new_paragraph._parent == blkcntnr assert blkcntnr._element.xml == expected_xml + def it_provides_access_to_the_global_bookmarks_collection_to_help( + self, bookmarks_fixture, part_prop_, bookmarks_ + ): + parent_part_ = bookmarks_fixture + parent_part_.bookmarks = bookmarks_ + part_prop_.return_value = parent_part_ + blkcntnr = BlockItemContainer(None, None) + + bookmarks = blkcntnr._bookmarks + + assert bookmarks is bookmarks_ + # fixtures ------------------------------------------------------- - @pytest.fixture(params=[ - ('', None), - ('Foo', None), - ('', 'Bar'), - ('Foo', 'Bar'), - ]) + @pytest.fixture(params=[("", None), ("Foo", None), ("", "Bar"), ("Foo", "Bar")]) def add_paragraph_fixture(self, request, paragraph_): text, style = request.param paragraph_.style = None @@ -85,37 +165,84 @@ def add_paragraph_fixture(self, request, paragraph_): @pytest.fixture def _add_paragraph_fixture(self, request): - blkcntnr_cxml, after_cxml = 'w:body', 'w:body/w:p' + blkcntnr_cxml, after_cxml = "w:body", "w:body/w:p" blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) expected_xml = xml(after_cxml) return blkcntnr, expected_xml @pytest.fixture def add_table_fixture(self): - blkcntnr = BlockItemContainer(element('w:body'), None) + blkcntnr = BlockItemContainer(element("w:body"), None) rows, cols, width = 2, 2, Inches(2) - expected_xml = snippet_seq('new-tbl')[0] + expected_xml = snippet_seq("new-tbl")[0] return blkcntnr, rows, cols, width, expected_xml - @pytest.fixture(params=[ - ('w:body', 0), - ('w:body/w:p', 1), - ('w:body/(w:p,w:p)', 2), - ('w:body/(w:p,w:tbl)', 1), - ('w:body/(w:p,w:tbl,w:p)', 2), - ]) + @pytest.fixture(params=[DocumentPart, HeaderPart, FooterPart]) + def bookmarks_fixture(self, request): + PartCls = request.param + parent_part_ = instance_mock(request, PartCls) + return parent_part_ + + @pytest.fixture( + params=[ + # ---document body--- + ("w:body", 0, "w:body/w:bookmarkEnd{w:id=0}"), + # ---table cell--- + ("w:tc/w:p", 1, "w:tc/(w:p,w:bookmarkEnd{w:id=1})"), + # ---header--- + ("w:hdr/w:p", 42, "w:hdr/(w:p,w:bookmarkEnd{w:id=42})"), + # ---footer--- + ("w:ftr/w:p", 24, "w:ftr/(w:p,w:bookmarkEnd{w:id=24})"), + ] + ) + def end_bookmark_fixture(self, request): + cxml, bookmark_id, expected_cxml = request.param + blockContainer = element(cxml) + expected_xml = xml(expected_cxml) + return blockContainer, bookmark_id, expected_xml + + @pytest.fixture( + params=[ + ("w:body", 0), + ("w:body/w:p", 1), + ("w:body/(w:p,w:p)", 2), + ("w:body/(w:p,w:tbl)", 1), + ("w:body/(w:p,w:tbl,w:p)", 2), + ] + ) def paragraphs_fixture(self, request): blkcntnr_cxml, expected_count = request.param blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) return blkcntnr, expected_count - @pytest.fixture(params=[ - ('w:body', 0), - ('w:body/w:tbl', 1), - ('w:body/(w:tbl,w:tbl)', 2), - ('w:body/(w:p,w:tbl)', 1), - ('w:body/(w:tbl,w:tbl,w:p)', 2), - ]) + @pytest.fixture( + params=[ + # ---document body--- + ("w:body", 0, "w:body/w:bookmarkStart{w:name=bmk-1, w:id=0}"), + # ---table cell--- + ("w:tc/w:p", 1, "w:tc/(w:p,w:bookmarkStart{w:name=bmk-1, w:id=1})"), + # ---header--- + ("w:hdr", 42, "w:hdr/(w:bookmarkStart{w:name=bmk-1, w:id=42})"), + # ---footer--- + ("w:ftr", 24, "w:ftr/(w:bookmarkStart{w:name=bmk-1, w:id=24})"), + ] + ) + def start_bookmark_fixture(self, request): + cxml, next_id, expected_cxml = request.param + blockContainer = element(cxml) + expected_xml = xml(expected_cxml) + name = "bmk-1" + return blockContainer, name, next_id, expected_xml + + @pytest.fixture( + params=[ + ("w:body", 0), + ("w:body/w:tbl", 1), + ("w:body/(w:tbl,w:tbl)", 2), + ("w:body/(w:p,w:tbl)", 1), + ("w:body/(w:tbl,w:tbl,w:p)", 2), + ] + ) def tables_fixture(self, request): blkcntnr_cxml, expected_count = request.param blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) @@ -125,8 +252,28 @@ def tables_fixture(self, request): @pytest.fixture def _add_paragraph_(self, request): - return method_mock(request, BlockItemContainer, '_add_paragraph') + return method_mock(request, BlockItemContainer, "_add_paragraph") + + @pytest.fixture + def _Bookmark_(self, request): + return class_mock(request, "docx.blkcntnr._Bookmark") + + @pytest.fixture + def bookmark_(self, request): + return instance_mock(request, _Bookmark) + + @pytest.fixture + def bookmarks_(self, request): + return instance_mock(request, Bookmarks) + + @pytest.fixture + def _bookmarks_prop_(self, request): + return property_mock(request, BlockItemContainer, "_bookmarks") @pytest.fixture def paragraph_(self, request): return instance_mock(request, Paragraph) + + @pytest.fixture + def part_prop_(self, request): + return property_mock(request, BlockItemContainer, "part") diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py new file mode 100644 index 000000000..64178ca64 --- /dev/null +++ b/tests/test_bookmark.py @@ -0,0 +1,544 @@ +# encoding: utf-8 + +"""Test suite for the docx.bookmark module.""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +import pytest + +from docx.bookmark import ( + _Bookmark, + Bookmarks, + _DocumentBookmarkFinder, + _PartBookmarkFinder, +) +from docx.opc.part import Part, XmlPart +from docx.parts.document import DocumentPart + +from .unitutil.cxml import element +from .unitutil.mock import ( + ANY, + call, + class_mock, + initializer_mock, + instance_mock, + method_mock, + property_mock, +) + + +class DescribeBookmarks(object): + """Unit-test suite for `docx.bookmark.Bookmarks` object.""" + + def it_knows_whether_it_contains_a_bookmark_by_name(self, contains_fixture, _iter_): + mock_bookmarks, name, expected_value = contains_fixture + _iter_.return_value = iter(mock_bookmarks) + bookmarks = Bookmarks(None) + + has_bookmark_with_name = name in bookmarks + + assert has_bookmark_with_name is expected_value + + def it_provides_access_to_bookmarks_by_index( + self, _finder_prop_, finder_, _Bookmark_, bookmark_ + ): + bookmarkStarts = tuple(element("w:bookmarkStart") for _ in range(3)) + bookmarkEnds = tuple(element("w:bookmarkEnd") for _ in range(3)) + _finder_prop_.return_value = finder_ + finder_.bookmark_pairs = list(zip(bookmarkStarts, bookmarkEnds)) + _Bookmark_.return_value = bookmark_ + bookmarks = Bookmarks(None) + + bookmark = bookmarks[1] + + _Bookmark_.assert_called_once_with((bookmarkStarts[1], bookmarkEnds[1])) + assert bookmark == bookmark_ + + def it_provides_access_to_bookmarks_by_slice( + self, _finder_prop_, finder_, _Bookmark_, bookmark_ + ): + bookmarkStarts = tuple(element("w:bookmarkStart") for _ in range(4)) + bookmarkEnds = tuple(element("w:bookmarkEnd") for _ in range(4)) + _finder_prop_.return_value = finder_ + finder_.bookmark_pairs = list(zip(bookmarkStarts, bookmarkEnds)) + _Bookmark_.return_value = bookmark_ + bookmarks = Bookmarks(None) + + bookmarks_slice = bookmarks[1:3] + + assert _Bookmark_.call_args_list == [ + call((bookmarkStarts[1], bookmarkEnds[1])), + call((bookmarkStarts[2], bookmarkEnds[2])), + ] + assert bookmarks_slice == [bookmark_, bookmark_] + + def it_can_iterate_its_bookmarks( + self, _finder_prop_, finder_, _Bookmark_, bookmark_ + ): + bookmarkStarts = tuple(element("w:bookmarkStart") for _ in range(3)) + bookmarkEnds = tuple(element("w:bookmarkEnd") for _ in range(3)) + _finder_prop_.return_value = finder_ + finder_.bookmark_pairs = list(zip(bookmarkStarts, bookmarkEnds)) + _Bookmark_.return_value = bookmark_ + bookmarks = Bookmarks(None) + + _bookmarks = list(b for b in bookmarks) + + assert _Bookmark_.call_args_list == [ + call((bookmarkStarts[0], bookmarkEnds[0])), + call((bookmarkStarts[1], bookmarkEnds[1])), + call((bookmarkStarts[2], bookmarkEnds[2])), + ] + assert _bookmarks == [bookmark_, bookmark_, bookmark_] + + def it_knows_how_many_bookmarks_the_document_contains(self, _finder_prop_, finder_): + _finder_prop_.return_value = finder_ + finder_.bookmark_pairs = tuple((1, 2) for _ in range(42)) + bookmarks = Bookmarks(None) + + count = len(bookmarks) + + assert count == 42 + + def it_provides_access_to_its_bookmarks_by_name( + self, bookmark_, bookmark_2_, _iter_ + ): + bookmark_.name = "foobar" + bookmark_2_.name = "barfoo" + _iter_.return_value = iter((bookmark_2_, bookmark_)) + bookmarks = Bookmarks(None) + + bookmark = bookmarks.get("foobar") + + assert bookmark is bookmark_ + + def but_it_raises_KeyError_when_no_bookmark_by_that_name(self, bookmark_, _iter_): + bookmark_.name = "foobar" + _iter_.return_value = iter((bookmark_,)) + bookmarks = Bookmarks(None) + + with pytest.raises(KeyError) as e: + bookmarks.get("barfoo") + assert e.value.args[0] == "Requested bookmark not found." + + def it_knows_the_next_available_bookmark_id(self, next_id_fixture, _iter_): + mock_bookmarks, expected_value = next_id_fixture + _iter_.return_value = iter(mock_bookmarks) + bookmarks = Bookmarks(None) + + next_id = bookmarks.next_id + + assert next_id is expected_value + + def it_provides_access_to_its_bookmark_finder_to_help( + self, document_part_, _DocumentBookmarkFinder_, finder_ + ): + _DocumentBookmarkFinder_.return_value = finder_ + bookmarks = Bookmarks(document_part_) + + finder = bookmarks._finder + + _DocumentBookmarkFinder_.assert_called_once_with(document_part_) + assert finder is finder_ + + # fixtures ------------------------------------------------------- + + @pytest.fixture( + params=[ + ((), "foo", False), + (("foo",), "foo", True), + (("foo",), "fiz", False), + (("foo", "bar", "baz"), "foo", True), + (("foo", "bar", "baz"), "fiz", False), + ] + ) + def contains_fixture(self, request): + member_names, name, expected_value = request.param + mock_bookmarks = tuple(instance_mock(request, _Bookmark) for _ in member_names) + # ---assign name seperately to avoid mock(.., "name") param collision--- + for idx, bookmark_ in enumerate(mock_bookmarks): + bookmark_.name = member_names[idx] + return mock_bookmarks, name, expected_value + + @pytest.fixture(params=[((), 1), ((1, 2, 3), 4), ((1, 3), 4), ((2, 42), 43)]) + def next_id_fixture(self, request): + bookmark_ids, expected_value = request.param + mock_bookmarks = tuple( + instance_mock(request, _Bookmark, id=bmid) for bmid in bookmark_ids + ) + return mock_bookmarks, expected_value + + # fixture components --------------------------------------------- + + @pytest.fixture + def _Bookmark_(self, request): + return class_mock(request, "docx.bookmark._Bookmark") + + @pytest.fixture + def bookmark_(self, request): + return instance_mock(request, _Bookmark) + + @pytest.fixture + def bookmark_2_(self, request): + return instance_mock(request, _Bookmark) + + @pytest.fixture + def _DocumentBookmarkFinder_(self, request): + return class_mock(request, "docx.bookmark._DocumentBookmarkFinder") + + @pytest.fixture + def document_part_(self, request): + return instance_mock(request, DocumentPart) + + @pytest.fixture + def finder_(self, request): + return instance_mock(request, _DocumentBookmarkFinder) + + @pytest.fixture + def _finder_prop_(self, request): + return property_mock(request, Bookmarks, "_finder") + + @pytest.fixture + def _iter_(self, request): + return method_mock(request, Bookmarks, "__iter__") + + +class Describe_Bookmark(object): + """Unit-test suite for `docx.bookmark._Bookmark` object.""" + + def it_knows_when_it_equals_another_bookmark_object(self): + bookmarkStart = element("w:bookmarkStart") + bookmarkStart_2 = element("w:bookmarkStart") + bookmarkEnd = element("w:bookmarkEnd") + bookmarkEnd_2 = element("w:bookmarkEnd") + + # ---open bookmark--- + assert _Bookmark((bookmarkStart, None)) == _Bookmark((bookmarkStart, None)) + # ---closed bookmark--- + assert _Bookmark((bookmarkStart, bookmarkEnd)) == _Bookmark( + (bookmarkStart, bookmarkEnd) + ) + # ---different bookmark--- + assert _Bookmark((bookmarkStart, bookmarkEnd)) != _Bookmark( + (bookmarkStart_2, bookmarkEnd_2) + ) + # ---not a bookmark--- + assert _Bookmark((bookmarkStart, bookmarkEnd)) != object() + + def it_can_close_itself_when_open(self): + bookmarkStart = element("w:bookmarkStart{w:id=42}") + bookmarkEnd = element("w:bookmarkEnd{w:id=42}") + bookmark = _Bookmark((bookmarkStart, None)) + + return_value = bookmark.close(bookmarkEnd) + + assert bookmark._bookmarkEnd == bookmarkEnd + assert return_value is bookmark + + def but_it_raises_if_it_is_already_closed(self): + bookmarkEnd = element("w:bookmarkEnd") + bookmark = _Bookmark((None, bookmarkEnd)) + + with pytest.raises(ValueError) as e: + bookmark.close(bookmarkEnd) + assert "bookmark already closed" in str(e.value) + + def and_it_raises_if_the_ids_dont_match(self): + bookmarkStart = element("w:bookmarkStart{w:id=42}") + bookmarkEnd = element("w:bookmarkEnd{w:id=24}") + bookmark = _Bookmark((bookmarkStart, None)) + + with pytest.raises(ValueError) as e: + bookmark.close(bookmarkEnd) + assert "end id does not match start id" in str(e.value) + + def it_knows_its_id(self): + bookmarkStart = element("w:bookmarkStart{w:id=42}") + bookmarkEnd = element("w:bookmarkEnd") + + bookmark = _Bookmark((bookmarkStart, bookmarkEnd)) + + assert bookmark.id == 42 + + def it_knows_whether_it_is_closed(self, is_closed_fixture): + bookmarkStart, bookmarkEnd, expected_value = is_closed_fixture + bookmark = _Bookmark((bookmarkStart, bookmarkEnd)) + + is_closed = bookmark.is_closed + + assert is_closed == expected_value + + def it_knows_its_name(self): + bookmarkStart = element("w:bookmarkStart{w:name=bmk-0}") + bookmarkEnd = element("w:bookmarkEnd") + + bookmark = _Bookmark((bookmarkStart, bookmarkEnd)) + + assert bookmark.name == "bmk-0" + + # fixtures ------------------------------------------------------- + + @pytest.fixture( + params=[ + (None, None, False), # ---not expected--- + ("w:bookmarkStart", None, False), + ("w:bookmarkStart", "w:bookmarkEnd", True), + ] + ) + def is_closed_fixture(self, request): + bookmarkStart_cxml, bookmarkEnd_cxml, expected_value = request.param + bookmarkStart = element(bookmarkStart_cxml) if bookmarkStart_cxml else None + bookmarkEnd = element(bookmarkEnd_cxml) if bookmarkEnd_cxml else None + return bookmarkStart, bookmarkEnd, expected_value + + +class Describe_DocumentBookmarkFinder(object): + def it_finds_all_the_bookmark_pairs_in_the_document( + self, pairs_fixture, _PartBookmarkFinder_ + ): + document_part_, calls, expected_value = pairs_fixture + document_bookmark_finder = _DocumentBookmarkFinder(document_part_) + + bookmark_pairs = document_bookmark_finder.bookmark_pairs + + document_part_.iter_story_parts.assert_called_once_with() + assert _PartBookmarkFinder_.iter_start_end_pairs.call_args_list == calls + assert bookmark_pairs == expected_value + + # fixtures ------------------------------------------------------- + + @pytest.fixture( + params=[ + ([[(1, 2)]], [(1, 2)]), + ([[(1, 2), (3, 4), (5, 6)]], [(1, 2), (3, 4), (5, 6)]), + ([[(1, 2)], [(3, 4)], [(5, 6)]], [(1, 2), (3, 4), (5, 6)]), + ( + [[(1, 2), (3, 4)], [(5, 6), (7, 8)], [(9, 10)]], + [(1, 2), (3, 4), (5, 6), (7, 8), (9, 10)], + ), + ] + ) + def pairs_fixture(self, request, document_part_, _PartBookmarkFinder_): + parts_pairs, expected_value = request.param + mock_parts = [ + instance_mock(request, Part, name="Part-%d" % idx) + for idx, part_pairs in enumerate(parts_pairs) + ] + calls = [call(part_) for part_ in mock_parts] + + document_part_.iter_story_parts.return_value = (p for p in mock_parts) + _PartBookmarkFinder_.iter_start_end_pairs.side_effect = parts_pairs + + return document_part_, calls, expected_value + + # fixture components --------------------------------------------- + + @pytest.fixture + def _PartBookmarkFinder_(self, request): + return class_mock(request, "docx.bookmark._PartBookmarkFinder") + + @pytest.fixture + def document_part_(self, request): + return instance_mock(request, DocumentPart) + + +class Describe_PartBookmarkFinder(object): + """Unit tests for _PartBookmarkFinder class""" + + def it_provides_an_iter_start_end_pairs_interface_method( + self, part_, _init_, _iter_start_end_pairs_ + ): + pairs = _PartBookmarkFinder.iter_start_end_pairs(part_) + + _init_.assert_called_once_with(ANY, part_) + _iter_start_end_pairs_.assert_called_once_with(ANY) + assert pairs == _iter_start_end_pairs_.return_value + + def it_gathers_all_the_bookmark_start_and_end_elements_to_help(self, part_): + body = element("w:body/(w:bookmarkStart,w:p,w:bookmarkEnd,w:p,w:bookmarkStart)") + part_.element = body + finder = _PartBookmarkFinder(part_) + + starts_and_ends = finder._all_starts_and_ends + + assert starts_and_ends == [body[0], body[2], body[4]] + + def it_iterates_start_end_pairs_to_help( + self, _iter_starts_, _matching_end_, _name_already_used_ + ): + bookmarkStarts = tuple( + element("w:bookmarkStart{w:name=%s,w:id=%d}" % (name, idx)) + for idx, name in enumerate(("bmk-0", "bmk-1", "bmk-2", "bmk-1")) + ) + bookmarkEnds = ( + None, + element("w:bookmarkEnd{w:id=1}"), + element("w:bookmarkEnd{w:id=2}"), + ) + _iter_starts_.return_value = iter(enumerate(bookmarkStarts)) + _matching_end_.side_effect = ( + None, + bookmarkEnds[1], + bookmarkEnds[2], + bookmarkEnds[1], + ) + _name_already_used_.side_effect = (False, False, True) + finder = _PartBookmarkFinder(None) + + start_end_pairs = list(finder._iter_start_end_pairs()) + + assert _matching_end_.call_args_list == [ + call(finder, bookmarkStarts[0], 0), + call(finder, bookmarkStarts[1], 1), + call(finder, bookmarkStarts[2], 2), + call(finder, bookmarkStarts[3], 3), + ] + assert _name_already_used_.call_args_list == [ + call(finder, "bmk-1"), + call(finder, "bmk-2"), + call(finder, "bmk-1"), + ] + assert start_end_pairs == [ + (bookmarkStarts[1], bookmarkEnds[1]), + (bookmarkStarts[2], bookmarkEnds[2]), + ] + + def it_iterates_bookmarkStart_elements_to_help(self, _all_starts_and_ends_prop_): + starts_and_ends = ( + element("w:bookmarkStart"), + element("w:bookmarkEnd"), + element("w:bookmarkStart"), + element("w:bookmarkEnd"), + element("w:bookmarkStart"), + element("w:bookmarkEnd"), + ) + _all_starts_and_ends_prop_.return_value = list(starts_and_ends) + finder = _PartBookmarkFinder(None) + + starts = list(finder._iter_starts()) + + assert starts == [ + (0, starts_and_ends[0]), + (2, starts_and_ends[2]), + (4, starts_and_ends[4]), + ] + + def it_finds_the_matching_end_for_a_start_to_help( + self, matching_end_fixture, _all_starts_and_ends_prop_ + ): + starts_and_ends, start_idx, expected_value = matching_end_fixture + _all_starts_and_ends_prop_.return_value = starts_and_ends + bookmarkStart = starts_and_ends[start_idx] + finder = _PartBookmarkFinder(None) + + bookmarkEnd = finder._matching_end(bookmarkStart, start_idx) + + assert bookmarkEnd == expected_value + + def it_knows_whether_a_bookmark_name_was_already_used( + self, name_used_fixture, _names_so_far_prop_, names_so_far_ + ): + name, is_used, calls, expected_value = name_used_fixture + _names_so_far_prop_.return_value = names_so_far_ + names_so_far_.__contains__.return_value = is_used + finder = _PartBookmarkFinder(None) + + already_used = finder._name_already_used(name) + + assert names_so_far_.add.call_args_list == calls + assert already_used is expected_value + + def it_composes_a_set_in_which_to_track_used_bookmark_names(self): + finder = _PartBookmarkFinder(None) + names_so_far = finder._names_so_far + assert names_so_far == set() + + # fixtures ------------------------------------------------------- + + @pytest.fixture( + params=[ + # ---no subsequent end--- + ([element("w:bookmarkStart{w:name=foo,w:id=0}")], 0, None), + # ---no matching end--- + ( + [element("w:bookmarkStart{w:id=0}"), element("w:bookmarkEnd{w:id=1}")], + 0, + None, + ), + # ---end immediately follows start--- + ( + [element("w:bookmarkStart{w:id=0}"), element("w:bookmarkEnd{w:id=0}")], + 0, + 1, + ), + # ---end separated from start by other start--- + ( + [ + element("w:bookmarkStart{w:name=foo,w:id=0}"), + element("w:bookmarkStart{w:name=bar,w:id=0}"), + element("w:bookmarkEnd{w:id=0}"), + ], + 0, + 2, + ), + # ---end separated from start by other end--- + ( + [ + element("w:bookmarkStart{w:name=foo,w:id=1}"), + element("w:bookmarkEnd{w:id=0}"), + element("w:bookmarkEnd{w:id=1}"), + ], + 0, + 2, + ), + ] + ) + def matching_end_fixture(self, request): + starts_and_ends, start_idx, end_idx = request.param + expected_value = None if end_idx is None else starts_and_ends[end_idx] + return starts_and_ends, start_idx, expected_value + + @pytest.fixture(params=[(True, True), (False, False)]) + def name_used_fixture(self, request): + is_used, expected_value = request.param + name = "George" + calls = [] if is_used else [call("George")] + return name, is_used, calls, expected_value + + # fixture components --------------------------------------------- + + @pytest.fixture + def _all_starts_and_ends_prop_(self, request): + return property_mock(request, _PartBookmarkFinder, "_all_starts_and_ends") + + @pytest.fixture + def _init_(self, request): + return initializer_mock(request, _PartBookmarkFinder) + + @pytest.fixture + def _iter_start_end_pairs_(self, request): + return method_mock(request, _PartBookmarkFinder, "_iter_start_end_pairs") + + @pytest.fixture + def _iter_starts_(self, request): + return method_mock(request, _PartBookmarkFinder, "_iter_starts") + + @pytest.fixture + def _matching_end_(self, request): + return method_mock(request, _PartBookmarkFinder, "_matching_end") + + @pytest.fixture + def _name_already_used_(self, request): + return method_mock(request, _PartBookmarkFinder, "_name_already_used") + + @pytest.fixture + def _names_so_far_prop_(self, request): + return property_mock(request, _PartBookmarkFinder, "_names_so_far") + + @pytest.fixture + def names_so_far_(self, request): + return instance_mock(request, set) + + @pytest.fixture + def part_(self, request): + return instance_mock(request, XmlPart) diff --git a/tests/test_document.py b/tests/test_document.py index 0de469c38..09e80f220 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -6,6 +6,7 @@ import pytest +from docx.bookmark import _Bookmark, Bookmarks from docx.document import _Body, Document from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK @@ -25,6 +26,7 @@ class DescribeDocument(object): + """Unit-test suite for `docx.document.Document` object.""" def it_can_add_a_heading(self, add_heading_fixture, add_paragraph_, paragraph_): level, style = add_heading_fixture @@ -77,7 +79,7 @@ def it_can_add_a_section( section = document.add_section(start_type) assert document.element.xml == expected_xml - sectPr = document.element.xpath('w:body/w:sectPr')[0] + sectPr = document.element.xpath("w:body/w:sectPr")[0] Section_.assert_called_once_with(sectPr, document_part_) assert section is section_ @@ -88,16 +90,29 @@ def it_can_add_a_table(self, add_table_fixture): assert table == table_ assert table.style == style - def it_can_save_the_document_to_a_file(self, save_fixture): - document, file_ = save_fixture - document.save(file_) - document._part.save.assert_called_once_with(file_) + def it_provides_access_to_its_bookmarks(self, document_part_, bookmarks_): + document_part_.bookmarks = bookmarks_ + document = Document(None, document_part_) + + bookmarks = document.bookmarks + + assert bookmarks is bookmarks_ def it_provides_access_to_its_core_properties(self, core_props_fixture): document, core_properties_ = core_props_fixture core_properties = document.core_properties assert core_properties is core_properties_ + def it_can_end_a_bookmark(self, _body_prop_, body_, bookmark_): + _body_prop_.return_value = body_ + body_.end_bookmark.return_value = bookmark_ + document = Document(None, None) + + bookmark = document.end_bookmark(bookmark_) + + body_.end_bookmark.assert_called_once_with(bookmark_) + assert bookmark is bookmark_ + def it_provides_access_to_its_inline_shapes(self, inline_shapes_fixture): document, inline_shapes_ = inline_shapes_fixture assert document.inline_shapes is inline_shapes_ @@ -107,8 +122,13 @@ def it_provides_access_to_its_paragraphs(self, paragraphs_fixture): paragraphs = document.paragraphs assert paragraphs is paragraphs_ + def it_can_save_the_document_to_a_file(self, save_fixture): + document, file_ = save_fixture + document.save(file_) + document._part.save.assert_called_once_with(file_) + def it_provides_access_to_its_sections(self, document_part_, Sections_, sections_): - document_elm = element('w:document') + document_elm = element("w:document") Sections_.return_value = sections_ document = Document(document_elm, document_part_) @@ -121,6 +141,16 @@ def it_provides_access_to_its_settings(self, settings_fixture): document, settings_ = settings_fixture assert document.settings is settings_ + def it_can_start_a_bookmark(self, _body_prop_, body_, bookmark_): + _body_prop_.return_value = body_ + body_.start_bookmark.return_value = bookmark_ + document = Document(None, None) + + bookmark = document.start_bookmark("foobar") + + body_.start_bookmark.assert_called_once_with("foobar") + assert bookmark is bookmark_ + def it_provides_access_to_its_styles(self, styles_fixture): document, styles_ = styles_fixture assert document.styles is styles_ @@ -148,57 +178,52 @@ def it_determines_block_width_to_help(self, block_width_fixture): # fixtures ------------------------------------------------------- - @pytest.fixture(params=[ - (0, 'Title'), - (1, 'Heading 1'), - (2, 'Heading 2'), - (9, 'Heading 9'), - ]) + @pytest.fixture( + params=[(0, "Title"), (1, "Heading 1"), (2, "Heading 2"), (9, "Heading 9")] + ) def add_heading_fixture(self, request): level, style = request.param return level, style - @pytest.fixture(params=[ - ('', None), - ('', 'Heading 1'), - ('foo\rbar', 'Body Text'), - ]) - def add_paragraph_fixture(self, request, body_prop_, paragraph_): + @pytest.fixture(params=[("", None), ("", "Heading 1"), ("foo\rbar", "Body Text")]) + def add_paragraph_fixture(self, request, _body_prop_, paragraph_): text, style = request.param document = Document(None, None) - body_prop_.return_value.add_paragraph.return_value = paragraph_ + _body_prop_.return_value.add_paragraph.return_value = paragraph_ return document, text, style, paragraph_ @pytest.fixture def add_picture_fixture(self, request, add_paragraph_, run_, picture_): document = Document(None, None) - path, width, height = 'foobar.png', 100, 200 + path, width, height = "foobar.png", 100, 200 add_paragraph_.return_value.add_run.return_value = run_ run_.add_picture.return_value = picture_ return document, path, width, height, run_, picture_ - @pytest.fixture(params=[ - ('w:sectPr', WD_SECTION.EVEN_PAGE, - 'w:sectPr/w:type{w:val=evenPage}'), - ('w:sectPr/w:type{w:val=evenPage}', WD_SECTION.ODD_PAGE, - 'w:sectPr/w:type{w:val=oddPage}'), - ('w:sectPr/w:type{w:val=oddPage}', WD_SECTION.NEW_PAGE, - 'w:sectPr'), - ]) + @pytest.fixture( + params=[ + ("w:sectPr", WD_SECTION.EVEN_PAGE, "w:sectPr/w:type{w:val=evenPage}"), + ( + "w:sectPr/w:type{w:val=evenPage}", + WD_SECTION.ODD_PAGE, + "w:sectPr/w:type{w:val=oddPage}", + ), + ("w:sectPr/w:type{w:val=oddPage}", WD_SECTION.NEW_PAGE, "w:sectPr"), + ] + ) def add_section_fixture(self, request): sentinel, start_type, new_sentinel = request.param - document_elm = element('w:document/w:body/(w:p,%s)' % sentinel) + document_elm = element("w:document/w:body/(w:p,%s)" % sentinel) expected_xml = xml( - 'w:document/w:body/(w:p,w:p/w:pPr/%s,%s)' % - (sentinel, new_sentinel) + "w:document/w:body/(w:p,w:p/w:pPr/%s,%s)" % (sentinel, new_sentinel) ) return document_elm, start_type, expected_xml @pytest.fixture - def add_table_fixture(self, _block_width_prop_, body_prop_, table_): + def add_table_fixture(self, _block_width_prop_, _body_prop_, table_): document = Document(None, None) - rows, cols, style = 4, 2, 'Light Shading Accent 1' - body_prop_.return_value.add_table.return_value = table_ + rows, cols, style = 4, 2, "Light Shading Accent 1" + _body_prop_.return_value.add_table.return_value = table_ _block_width_prop_.return_value = width = 42 return document, rows, cols, style, width, table_ @@ -214,7 +239,7 @@ def block_width_fixture(self, sections_prop_, section_): @pytest.fixture def body_fixture(self, _Body_, body_): - document_elm = element('w:document/w:body') + document_elm = element("w:document/w:body") body_elm = document_elm[0] document = Document(document_elm, None) return document, body_elm, _Body_, body_ @@ -232,9 +257,9 @@ def inline_shapes_fixture(self, document_part_, inline_shapes_): return document, inline_shapes_ @pytest.fixture - def paragraphs_fixture(self, body_prop_, paragraphs_): + def paragraphs_fixture(self, _body_prop_, paragraphs_): document = Document(None, None) - body_prop_.return_value.paragraphs = paragraphs_ + _body_prop_.return_value.paragraphs = paragraphs_ return document, paragraphs_ @pytest.fixture @@ -245,7 +270,7 @@ def part_fixture(self, document_part_): @pytest.fixture def save_fixture(self, document_part_): document = Document(None, document_part_) - file_ = 'foobar.docx' + file_ = "foobar.docx" return document, file_ @pytest.fixture @@ -261,32 +286,40 @@ def styles_fixture(self, document_part_, styles_): return document, styles_ @pytest.fixture - def tables_fixture(self, body_prop_, tables_): + def tables_fixture(self, _body_prop_, tables_): document = Document(None, None) - body_prop_.return_value.tables = tables_ + _body_prop_.return_value.tables = tables_ return document, tables_ # fixture components --------------------------------------------- @pytest.fixture def add_paragraph_(self, request): - return method_mock(request, Document, 'add_paragraph') + return method_mock(request, Document, "add_paragraph") + + @pytest.fixture + def _block_width_prop_(self, request): + return property_mock(request, Document, "_block_width") @pytest.fixture def _Body_(self, request, body_): - return class_mock(request, 'docx.document._Body', return_value=body_) + return class_mock(request, "docx.document._Body", return_value=body_) @pytest.fixture def body_(self, request): return instance_mock(request, _Body) @pytest.fixture - def _block_width_prop_(self, request): - return property_mock(request, Document, '_block_width') + def _body_prop_(self, request, body_): + return property_mock(request, Document, "_body") @pytest.fixture - def body_prop_(self, request, body_): - return property_mock(request, Document, '_body', return_value=body_) + def bookmark_(self, request): + return instance_mock(request, _Bookmark) + + @pytest.fixture + def bookmarks_(self, request): + return instance_mock(request, Bookmarks) @pytest.fixture def core_properties_(self, request): @@ -318,7 +351,7 @@ def run_(self, request): @pytest.fixture def Section_(self, request): - return class_mock(request, 'docx.document.Section') + return class_mock(request, "docx.document.Section") @pytest.fixture def section_(self, request): @@ -326,7 +359,7 @@ def section_(self, request): @pytest.fixture def Sections_(self, request): - return class_mock(request, 'docx.document.Sections') + return class_mock(request, "docx.document.Sections") @pytest.fixture def sections_(self, request): @@ -334,7 +367,7 @@ def sections_(self, request): @pytest.fixture def sections_prop_(self, request): - return property_mock(request, Document, 'sections') + return property_mock(request, Document, "sections") @pytest.fixture def settings_(self, request): @@ -346,7 +379,7 @@ def styles_(self, request): @pytest.fixture def table_(self, request): - return instance_mock(request, Table, style='UNASSIGNED') + return instance_mock(request, Table, style="UNASSIGNED") @pytest.fixture def tables_(self, request): @@ -354,7 +387,6 @@ def tables_(self, request): class Describe_Body(object): - def it_can_clear_itself_of_all_content_it_holds(self, clear_fixture): body, expected_xml = clear_fixture _body = body.clear_content() @@ -363,12 +395,14 @@ def it_can_clear_itself_of_all_content_it_holds(self, clear_fixture): # fixtures ------------------------------------------------------- - @pytest.fixture(params=[ - ('w:body', 'w:body'), - ('w:body/w:p', 'w:body'), - ('w:body/w:sectPr', 'w:body/w:sectPr'), - ('w:body/(w:p, w:sectPr)', 'w:body/w:sectPr'), - ]) + @pytest.fixture( + params=[ + ("w:body", "w:body"), + ("w:body/w:p", "w:body"), + ("w:body/w:sectPr", "w:body/w:sectPr"), + ("w:body/(w:p, w:sectPr)", "w:body/w:sectPr"), + ] + ) def clear_fixture(self, request): before_cxml, after_cxml = request.param body = _Body(element(before_cxml), None) diff --git a/tests/unitdata.py b/tests/unitdata.py index 208be48de..1b7c9ed96 100644 --- a/tests/unitdata.py +++ b/tests/unitdata.py @@ -122,7 +122,7 @@ def _non_empty_element_xml(self, indent): else: xml = '%s%s\n' % (indent_str, self._start_tag) for child_bldr in self._child_bldrs: - xml += child_bldr.xml(indent+2) + xml += child_bldr.xml(indent + 2) xml += '%s%s' % (indent_str, self._end_tag) return xml diff --git a/tests/unitutil/cxml.py b/tests/unitutil/cxml.py index f583a3c99..7e4bb2635 100644 --- a/tests/unitutil/cxml.py +++ b/tests/unitutil/cxml.py @@ -171,7 +171,7 @@ def _xml(self, indent): self._indent_str = ' ' * indent xml = self._start_tag for child in self._children: - xml += child._xml(indent+2) + xml += child._xml(indent + 2) xml += self._end_tag return xml @@ -256,27 +256,23 @@ def grammar(): # w:jc{val=right} ---------------------------- element = ( - tagname('tagname') - + Group(Optional(attr_list))('attr_list') - + Optional(text, default='')('text') + tagname('tagname') + + Group(Optional(attr_list))('attr_list') + + Optional(text, default='')('text') ).setParseAction(Element.from_token) child_node_list = Forward() node = Group( - element('element') - + Group(Optional(slash + child_node_list))('child_node_list') + element('element') + Group(Optional(slash + child_node_list))('child_node_list') ).setParseAction(connect_node_children) - child_node_list << ( - open_paren + delimitedList(node) + close_paren - | node - ) + child_node_list << (open_paren + delimitedList(node) + close_paren | node) root_node = ( - element('element') - + Group(Optional(slash + child_node_list))('child_node_list') - + stringEnd + element('element') + + Group(Optional(slash + child_node_list))('child_node_list') + + stringEnd ).setParseAction(connect_root_node_children) return root_node diff --git a/tests/unitutil/file.py b/tests/unitutil/file.py index 8462e6c42..374c69796 100644 --- a/tests/unitutil/file.py +++ b/tests/unitutil/file.py @@ -37,7 +37,7 @@ def snippet_seq(name, offset=0, count=1024): with open(path, 'rb') as f: text = f.read().decode('utf-8') snippets = text.split('\n\n') - start, end = offset, offset+count + start, end = offset, offset + count return tuple(snippets[start:end]) diff --git a/tests/unitutil/mock.py b/tests/unitutil/mock.py index 828382e7e..6a9c79cff 100644 --- a/tests/unitutil/mock.py +++ b/tests/unitutil/mock.py @@ -6,12 +6,10 @@ import sys -if sys.version_info >= (3, 3): - from unittest import mock # noqa +if sys.version_info > (3, 0): from unittest.mock import ANY, call, MagicMock # noqa from unittest.mock import create_autospec, Mock, mock_open, patch, PropertyMock else: - import mock # noqa from mock import ANY, call, MagicMock # noqa from mock import create_autospec, Mock, patch, PropertyMock @@ -70,9 +68,7 @@ def instance_mock(request, cls, name=None, spec_set=True, **kwargs): the Mock() call that creates the mock. """ name = name if name is not None else request.fixturename - return create_autospec( - cls, _name=name, spec_set=spec_set, instance=True, **kwargs - ) + return create_autospec(cls, _name=name, spec_set=spec_set, instance=True, **kwargs) def loose_mock(request, name=None, **kwargs): @@ -97,10 +93,8 @@ def method_mock(request, cls, method_name, autospec=True, **kwargs): def open_mock(request, module_name, **kwargs): - """ - Return a mock for the builtin `open()` method in *module_name*. - """ - target = '%s.open' % module_name + """Return a mock for the builtin `open()` method in *module_name*.""" + target = "%s.open" % module_name _patch = patch(target, mock_open(), create=True, **kwargs) request.addfinalizer(_patch.stop) return _patch.start() diff --git a/tox.ini b/tox.ini index 6ced79b71..181911c9f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,43 +1,42 @@ # # Configuration for tox and pytest - [flake8] exclude = dist,docs,*.egg-info,.git,ref,_scratch,.tox +ignore = + E203 ; whitespace before ':' + E241 ; multiple spaces after comma + W503 ; line break before binary operator (e.g. '+', 'and') + W504 ; line break after binary operator (e.g. '+', 'and') max-line-length = 88 [pytest] -norecursedirs = doc docx *.egg-info features .git ref _scratch .tox python_files = test_*.py python_classes = Test Describe -python_functions = it_ they_ and_it_ but_it_ +python_functions = it_ they_ but_ and_it_ +testpaths = tests [tox] envlist = py26, py27, py34, py35, py36, py38 -[testenv] +[testenv:py27] deps = behave lxml + mock pyparsing pytest commands = py.test -qx - behave --format progress --stop --tags=-wip + behave --format progress -[testenv:py26] +[testenv] deps = - importlib>=1.0.3 behave lxml - mock pyparsing pytest -[testenv:py27] -deps = - behave - lxml - mock - pyparsing - pytest +commands = + py.test -qx + behave --format progress