From 90fc91c7570c24fd983e63daef1036ac01d03193 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 8 Jan 2019 23:12:12 -0800 Subject: [PATCH 01/52] release: prepare v0.8.10 release --- HISTORY.rst | 7 +++++++ docx/__init__.py | 2 +- docx/api.py | 2 +- docx/templates/default.docx | Bin 0 -> 38116 bytes 4 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 docx/templates/default.docx diff --git a/HISTORY.rst b/HISTORY.rst index b00569153..5612cbf05 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,13 @@ Release History --------------- +0.8.10 (2019-01-08) ++++++++++++++++++++ + +- Revert use of expanded package directory for default.docx to work around setup.py + problem with filenames containing square brackets. + + 0.8.9 (2019-01-08) ++++++++++++++++++ diff --git a/docx/__init__.py b/docx/__init__.py index 33e15e31a..4dae2946b 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.8.9' +__version__ = '0.8.10' # register custom Part classes with opc package reader diff --git a/docx/api.py b/docx/api.py index 9a2215d7a..63e18c406 100644 --- a/docx/api.py +++ b/docx/api.py @@ -34,4 +34,4 @@ def _default_docx_path(): Return the path to the built-in default .docx package. """ _thisdir = os.path.split(__file__)[0] - return os.path.join(_thisdir, 'templates', 'default-docx-template') + return os.path.join(_thisdir, 'templates', 'default.docx') diff --git a/docx/templates/default.docx b/docx/templates/default.docx new file mode 100644 index 0000000000000000000000000000000000000000..c22ff36205f90636c3057b94f99157be64203896 GIT binary patch literal 38116 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 Date: Mon, 10 Jun 2019 15:16:10 -0700 Subject: [PATCH 02/52] fix: tweak setup.py handling of template directory Also some pending documentation updates and some Black changes. --- docs/user/hdrftr.rst | 25 +++++++++---------------- docx/compat.py | 14 +++++--------- docx/oxml/ns.py | 31 ++++++++++++++++--------------- docx/section.py | 11 ++++++----- setup.py | 2 +- 5 files changed, 37 insertions(+), 46 deletions(-) diff --git a/docs/user/hdrftr.rst b/docs/user/hdrftr.rst index b80420b8a..040da7ad3 100644 --- a/docs/user/hdrftr.rst +++ b/docs/user/hdrftr.rst @@ -77,24 +77,13 @@ Adding "zoned" header content ----------------------------- A header with multiple "zones" is often accomplished using carefully placed tab stops. -The required tab-stops for a center and right-aligned "zone" are part of the latent -``Header`` style in Word, but that style is not present in the default ``python-docx`` -template and will need to be added:: - >>> from docx.enum.style import WD_STYLE_TYPE - >>> from docx.enum.text import WD_TAB_ALIGNMENT - >>> styles = document.styles - >>> style = styles.add_style("Header", WD_STYLE_TYPE.PARAGRAPH) - >>> style.base_style = styles["Normal"] - >>> tab_stops = style.paragraph_format.tab_stops - >>> tab_stops.add_tab_stop(Inches(3.25), WD_TAB_ALIGNMENT.CENTER) - >>> tab_stops.add_tab_stop(Inches(6.5), WD_TAB_ALIGNMENT.RIGHT) +The required tab-stops for a center and right-aligned "zone" are part of the ``Header`` +and ``Footer`` styles in Word. If you're using a custom template rather than the +`python-docx` default, it probably makes sense to define that style in your template. -If you're using a custom template rather than the `python-docx` default, it probably -makes sense to define that style in your template. - -Once the ``Header`` style is present, tabs are used to separate left, center, and -right-aligned header content:: +Inserted tab characters (``"\t"``) are used to separate left, center, and right-aligned +header content:: >>> paragraph = header.paragraphs[0] >>> paragraph.text = "Left Text\tCenter Text\tRight Text" @@ -103,6 +92,10 @@ right-aligned header content:: .. image:: /_static/img/hdrftr-02.png :scale: 75% +The ``Header`` style is automatically applied to a new header, so the third line just +above (applying the ``Header`` style) is unnecessary in this case, but included here to +illustrate the general case. + Removing a header ----------------- diff --git a/docx/compat.py b/docx/compat.py index dc9e20e39..98ab9051c 100644 --- a/docx/compat.py +++ b/docx/compat.py @@ -4,9 +4,7 @@ Provides Python 2/3 compatibility objects """ -from __future__ import ( - absolute_import, division, print_function, unicode_literals -) +from __future__ import absolute_import, division, print_function, unicode_literals import sys @@ -16,12 +14,11 @@ if sys.version_info >= (3, 0): + from collections.abc import Sequence from io import BytesIO def is_string(obj): - """ - Return True if *obj* is a string, False otherwise. - """ + """Return True if *obj* is a string, False otherwise.""" return isinstance(obj, str) Unicode = str @@ -32,12 +29,11 @@ def is_string(obj): else: + from collections import Sequence # noqa from StringIO import StringIO as BytesIO # noqa def is_string(obj): - """ - Return True if *obj* is a string, False otherwise. - """ + """Return True if *obj* is a string, False otherwise.""" return isinstance(obj, basestring) Unicode = unicode diff --git a/docx/oxml/ns.py b/docx/oxml/ns.py index c240366da..6b0861284 100644 --- a/docx/oxml/ns.py +++ b/docx/oxml/ns.py @@ -8,21 +8,22 @@ nsmap = { - "a": ("http://schemas.openxmlformats.org/drawingml/2006/main"), - "c": ("http://schemas.openxmlformats.org/drawingml/2006/chart"), - "cp": ("http://schemas.openxmlformats.org/package/2006/metadata/core-properties"), - "dc": ("http://purl.org/dc/elements/1.1/"), - "dcmitype": ("http://purl.org/dc/dcmitype/"), - "dcterms": ("http://purl.org/dc/terms/"), - "dgm": ("http://schemas.openxmlformats.org/drawingml/2006/diagram"), - "m": ("http://schemas.openxmlformats.org/officeDocument/2006/math"), - "pic": ("http://schemas.openxmlformats.org/drawingml/2006/picture"), - "r": ("http://schemas.openxmlformats.org/officeDocument/2006/relationships"), - "sl": ("http://schemas.openxmlformats.org/schemaLibrary/2006/main"), - "w": ("http://schemas.openxmlformats.org/wordprocessingml/2006/main"), - "wp": ("http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"), - "xml": ("http://www.w3.org/XML/1998/namespace"), - "xsi": ("http://www.w3.org/2001/XMLSchema-instance"), + "a": "http://schemas.openxmlformats.org/drawingml/2006/main", + "c": "http://schemas.openxmlformats.org/drawingml/2006/chart", + "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", + "dc": "http://purl.org/dc/elements/1.1/", + "dcmitype": "http://purl.org/dc/dcmitype/", + "dcterms": "http://purl.org/dc/terms/", + "dgm": "http://schemas.openxmlformats.org/drawingml/2006/diagram", + "m": "http://schemas.openxmlformats.org/officeDocument/2006/math", + "pic": "http://schemas.openxmlformats.org/drawingml/2006/picture", + "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "sl": "http://schemas.openxmlformats.org/schemaLibrary/2006/main", + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + 'w14': "http://schemas.microsoft.com/office/word/2010/wordml", + "wp": "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", + "xml": "http://www.w3.org/XML/1998/namespace", + "xsi": "http://www.w3.org/2001/XMLSchema-instance", } pfxmap = dict((value, key) for key, value in nsmap.items()) diff --git a/docx/section.py b/docx/section.py index 77798ca5d..32ceec7da 100644 --- a/docx/section.py +++ b/docx/section.py @@ -4,9 +4,8 @@ from __future__ import absolute_import, division, print_function, unicode_literals -from collections import Sequence - from docx.blkcntnr import BlockItemContainer +from docx.compat import Sequence from docx.enum.section import WD_HEADER_FOOTER from docx.shared import lazyproperty @@ -81,7 +80,7 @@ def even_page_footer(self): The content of this footer definition is ignored unless the document setting :attr:`~.Settings.odd_and_even_pages_header_footer` is set True. """ - return _Footer(self._sectPr, self._document_part, WD_HEADER_FOOTER.EVEN_PAGE) + return _Footer(self._sectPr, self._document_part, WD_HEADER_FOOTER.EVEN_PAGE) @property def even_page_header(self): @@ -394,7 +393,8 @@ def _prior_headerfooter(self): """|_Footer| proxy on prior sectPr element or None if this is first section.""" preceding_sectPr = self._sectPr.preceding_sectPr return ( - None if preceding_sectPr is None + None + if preceding_sectPr is None else _Footer(preceding_sectPr, self._document_part, self._hdrftr_index) ) @@ -437,6 +437,7 @@ def _prior_headerfooter(self): """|_Header| proxy on prior sectPr element or None if this is first section.""" preceding_sectPr = self._sectPr.preceding_sectPr return ( - None if preceding_sectPr is None + None + if preceding_sectPr is None else _Header(preceding_sectPr, self._document_part, self._hdrftr_index) ) diff --git a/setup.py b/setup.py index 098d28e69..f0b3ef54d 100644 --- a/setup.py +++ b/setup.py @@ -35,7 +35,7 @@ def text_of(relpath): URL = 'https://github.com/python-openxml/python-docx' LICENSE = text_of('LICENSE') PACKAGES = find_packages(exclude=['tests', 'tests.*']) -PACKAGE_DATA = {'docx': ['templates/*']} +PACKAGE_DATA = {'docx': ['templates/*.xml', 'templates/*.docx']} INSTALL_REQUIRES = ['lxml>=2.3.2'] TEST_SUITE = 'tests' From b497d8050d2d685f196fbac75583966518c909fe Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Wed, 15 Nov 2017 18:03:54 +0100 Subject: [PATCH 03/52] docs: document Bookmarks analysis --- docs/dev/analysis/features/bookmarks.rst | 251 +++++++++++++++++++++++ docs/dev/analysis/index.rst | 1 + 2 files changed, 252 insertions(+) create mode 100644 docs/dev/analysis/features/bookmarks.rst diff --git a/docs/dev/analysis/features/bookmarks.rst b/docs/dev/analysis/features/bookmarks.rst new file mode 100644 index 000000000..46d433608 --- /dev/null +++ b/docs/dev/analysis/features/bookmarks.rst @@ -0,0 +1,251 @@ + +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') + docx.text.bookmark.Bookmark object at 0x00fa1afe1> + >>> bookmarks.get(id=1) + docx.text.bookmark.Bookmark object at 0x00fa1afe1> + >>> bookmarks[0] + docx.text.bookmark.Bookmark object at 0x00fa1afe1> + + +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. + +* ? Do bookmarks need to be unique across all stories? (like headers, footers, + etc.)? This could be trouble for us because we don't yet have access to + those "stories". + +* ? How do overlapping bookmarks behave? Are those permitted? Like new one + starts before prior one finishes? + + ? What about "nested" bookmarks? Are those permitted? Line second bookmark + starts and ends after first one starts and before it ends? + +* A bookmark can be added in five different document parts: Body, Header, + Footer, Footnote and Endnote. + + +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 + +* Bookmarks.Add(name, range) +* Bookmarks.Exists(name) +* Bookmarks.Item(index) +* Bookmarks.DefaultSorting +* Bookmarks.ShowHidden + +.. _Bookmark objects: + https://msdn.microsoft.com/en-us/vba/word-vba/articles/bookmark-object-word + +* Bookmark.Delete() +* Bookmark.Column (boolean) +* Bookmark.Empty (boolean, True if contains no text.) +* Bookmark.End +* Bookmark.Name +* Bookmark.Start +* Bookmark.StoryType + + +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 From 60d8e57f4b131f353e76443bbc189f6e4b81e9ba Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Sat, 25 Aug 2018 12:54:52 -0700 Subject: [PATCH 04/52] acpt: Add scenario for document.bookmarks --- features/doc-access-collections.feature | 6 ++++++ features/steps/document.py | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/features/doc-access-collections.feature b/features/doc-access-collections.feature index 0233d5989..822b3444a 100644 --- a/features/doc-access-collections.feature +++ b/features/doc-access-collections.feature @@ -27,3 +27,9 @@ 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 + + + @wip + Scenario: Document.bookmarks + Given a Document object as document + Then document.bookmarks is a Bookmarks object diff --git a/features/steps/document.py b/features/steps/document.py index a8e4d1adf..f99a3a6cf 100644 --- a/features/steps/document.py +++ b/features/steps/document.py @@ -27,6 +27,11 @@ def given_a_blank_document(context): 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') def given_a_document_having_builtin_styles(context): context.document = Document() @@ -172,6 +177,13 @@ def when_I_execute_section_eq_document_add_section(context): # then ==================================================== +@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 From 8dc7a3e357849d0bba57dd0e381f1a1160c9e29f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Jul 2019 16:34:18 -0700 Subject: [PATCH 05/52] rfctr: blacken docs/conf.py --- docs/conf.py | 59 ++++++++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 1a988453a..0aeae6e51 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 @@ -193,7 +193,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 +211,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 +221,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 +229,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 +250,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 +263,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 +297,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 +305,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 +318,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 +347,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 +360,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 +382,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} From afd1cc39c7a7a8b22f2fe9535a3716110b6edcc2 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Sat, 25 Aug 2018 13:29:11 -0700 Subject: [PATCH 06/52] doc: add Document.bookmarks --- docs/conf.py | 4 ++++ docx/bookmark.py | 14 +++++++++++++ docx/document.py | 23 +++++++++++++++------ features/doc-access-collections.feature | 1 - tests/test_document.py | 27 +++++++++++++++++++++---- 5 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 docx/bookmark.py diff --git a/docs/conf.py b/docs/conf.py index 0aeae6e51..8788e60ac 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -77,6 +77,10 @@ .. |_Body| replace:: :class:`._Body` +.. |Bookmark| replace:: :class:`.Bookmark` + +.. |Bookmarks| replace:: :class:`.Bookmarks` + .. |_Cell| replace:: :class:`._Cell` .. |_CharacterStyle| replace:: :class:`._CharacterStyle` diff --git a/docx/bookmark.py b/docx/bookmark.py new file mode 100644 index 000000000..565014b5c --- /dev/null +++ b/docx/bookmark.py @@ -0,0 +1,14 @@ +# encoding: utf-8 + +"""Objects related to bookmarks.""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + + +class Bookmarks(object): + """Sequence of |Bookmark| objects.""" + + def __init__(self, document_part): + self._document_part = document_part diff --git a/docx/document.py b/docx/document.py index 6493c458b..f511bb4c7 100644 --- a/docx/document.py +++ b/docx/document.py @@ -5,10 +5,12 @@ from __future__ import absolute_import, division, print_function, unicode_literals from docx.blkcntnr import BlockItemContainer + +from docx.bookmark import Bookmarks 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 +20,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 +46,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 +95,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 Bookmarks(self._part) + @property def core_properties(self): """ @@ -172,9 +184,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 +201,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/features/doc-access-collections.feature b/features/doc-access-collections.feature index 822b3444a..5c1b8d64b 100644 --- a/features/doc-access-collections.feature +++ b/features/doc-access-collections.feature @@ -29,7 +29,6 @@ Feature: Access document collections Then document.tables is a list containing three tables - @wip Scenario: Document.bookmarks Given a Document object as document Then document.bookmarks is a Bookmarks object diff --git a/tests/test_document.py b/tests/test_document.py index 0de469c38..9e05b2e22 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -6,6 +6,7 @@ import pytest +from docx.bookmark import Bookmarks from docx.document import _Body, Document from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK @@ -93,6 +94,16 @@ def it_can_save_the_document_to_a_file(self, save_fixture): document.save(file_) document._part.save.assert_called_once_with(file_) + def it_provides_access_to_its_bookmarks( + self, document_part_, Bookmarks_, bookmarks_): + Bookmarks_.return_value = bookmarks_ + document = Document(None, document_part_) + + bookmarks = document.bookmarks + + Bookmarks_.assert_called_once_with(document_part_) + 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 @@ -272,6 +283,10 @@ def tables_fixture(self, body_prop_, tables_): def add_paragraph_(self, request): 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_) @@ -281,12 +296,16 @@ 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 Bookmarks_(self, request): + return class_mock(request, 'docx.document.Bookmarks') + + @pytest.fixture + def bookmarks_(self, request): + return instance_mock(request, Bookmarks) @pytest.fixture def core_properties_(self, request): From e17e50d65949ef06895d5fb9b69b637356ac0236 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Fri, 31 Aug 2018 12:26:05 -0700 Subject: [PATCH 07/52] acpt: add scenario for Bookmarks --- features/bmk-bookmarks.feature | 12 +++++ features/steps/bookmarks.py | 45 +++++++++++++++++++ features/steps/test_files/bmk-bookmarks.docx | Bin 0 -> 16276 bytes 3 files changed, 57 insertions(+) create mode 100644 features/bmk-bookmarks.feature create mode 100644 features/steps/bookmarks.py create mode 100644 features/steps/test_files/bmk-bookmarks.docx diff --git a/features/bmk-bookmarks.feature b/features/bmk-bookmarks.feature new file mode 100644 index 000000000..842a0be65 --- /dev/null +++ b/features/bmk-bookmarks.feature @@ -0,0 +1,12 @@ +Feature: Access a bookmark + In order to operate on document bookmark objects + As a developer using python-docx + I need sequence operations on Bookmarks + + + @wip + 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 diff --git a/features/steps/bookmarks.py b/features/steps/bookmarks.py new file mode 100644 index 000000000..8c8197fc8 --- /dev/null +++ b/features/steps/bookmarks.py @@ -0,0 +1,45 @@ +# 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('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/test_files/bmk-bookmarks.docx b/features/steps/test_files/bmk-bookmarks.docx new file mode 100644 index 0000000000000000000000000000000000000000..1fa4fe7293af8ee9d4d75d6041b8ecfcc7190b34 GIT binary patch literal 16276 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¬3{=Lc39DzA54 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 literal 0 HcmV?d00001 From 3e0923429c3955be55c9b44edd3e4ea26e8e1cce Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 8 Sep 2018 22:39:27 -0700 Subject: [PATCH 08/52] bmk: add Bookmarks.__len__() --- docx/bookmark.py | 31 +++++++++++++++++++++++++++++++ tests/test_bookmark.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 tests/test_bookmark.py diff --git a/docx/bookmark.py b/docx/bookmark.py index 565014b5c..4ff725378 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -6,9 +6,40 @@ absolute_import, division, print_function, unicode_literals ) +from docx.shared import lazyproperty + class Bookmarks(object): """Sequence of |Bookmark| objects.""" def __init__(self, document_part): self._document_part = document_part + + def __len__(self): + return len(self._finder.bookmark_pairs) + + @lazyproperty + def _finder(self): + """_DocumentBookmarkFinder instance for this document.""" + raise NotImplementedError + + +class _DocumentBookmarkFinder(object): + """Provides access to bookmark oxml elements in an overall document.""" + + @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. + """ + raise NotImplementedError diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py new file mode 100644 index 000000000..82b810b74 --- /dev/null +++ b/tests/test_bookmark.py @@ -0,0 +1,36 @@ +# 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 Bookmarks, _DocumentBookmarkFinder + +from .unitutil.mock import instance_mock, property_mock + + +class DescribeBookmarks(object): + + 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 + + # fixture components --------------------------------------------- + + @pytest.fixture + def finder_(self, request): + return instance_mock(request, _DocumentBookmarkFinder) + + @pytest.fixture + def _finder_prop_(self, request): + return property_mock(request, Bookmarks, '_finder') From f4b22916dd3fd739b2774336557ce8f04fec8ae3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 9 Sep 2018 15:23:44 -0700 Subject: [PATCH 09/52] bmk: add Bookmarks._finder --- docx/bookmark.py | 5 ++++- tests/test_bookmark.py | 21 ++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docx/bookmark.py b/docx/bookmark.py index 4ff725378..846f6a2db 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -21,12 +21,15 @@ def __len__(self): @lazyproperty def _finder(self): """_DocumentBookmarkFinder instance for this document.""" - raise NotImplementedError + return _DocumentBookmarkFinder(self._document_part) 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. diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py index 82b810b74..8cb6340df 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -9,8 +9,9 @@ import pytest from docx.bookmark import Bookmarks, _DocumentBookmarkFinder +from docx.parts.document import DocumentPart -from .unitutil.mock import instance_mock, property_mock +from .unitutil.mock import class_mock, instance_mock, property_mock class DescribeBookmarks(object): @@ -25,8 +26,26 @@ def it_knows_how_many_bookmarks_the_document_contains( assert count == 42 + 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_ + # fixture components --------------------------------------------- + @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) From 959b554d7e8c09605f4217159f52763c294d2176 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 9 Sep 2018 16:18:09 -0700 Subject: [PATCH 10/52] bmk: add _DocumentBookmarkFinder.bookmark_pairs --- docx/bookmark.py | 16 +++++++++++++ docx/parts/document.py | 35 +++++++++++++++++++++++++++ tests/test_bookmark.py | 54 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 1 deletion(-) diff --git a/docx/bookmark.py b/docx/bookmark.py index 846f6a2db..483303ca1 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -6,6 +6,8 @@ absolute_import, division, print_function, unicode_literals ) +from itertools import chain + from docx.shared import lazyproperty @@ -45,4 +47,18 @@ def bookmark_pairs(self): 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.""" + + @classmethod + def iter_start_end_pairs(cls, part): + """Generate each (bookmarkStart, bookmarkEnd) in *part*.""" raise NotImplementedError diff --git a/docx/parts/document.py b/docx/parts/document.py index 59d0b7a71..45bee1a30 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -6,6 +6,7 @@ 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 @@ -89,6 +90,40 @@ 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. + """ + raise NotImplementedError + + 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/tests/test_bookmark.py b/tests/test_bookmark.py index 8cb6340df..0395fd56a 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -9,9 +9,10 @@ import pytest from docx.bookmark import Bookmarks, _DocumentBookmarkFinder +from docx.opc.part import Part from docx.parts.document import DocumentPart -from .unitutil.mock import class_mock, instance_mock, property_mock +from .unitutil.mock import call, class_mock, instance_mock, property_mock class DescribeBookmarks(object): @@ -53,3 +54,54 @@ def finder_(self, request): @pytest.fixture def _finder_prop_(self, request): return property_mock(request, Bookmarks, '_finder') + + +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) From 3bb9b96d6d1eb2e83f7224f5e1bc2d375bf4b9c4 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Mon, 24 Dec 2018 10:38:54 -0800 Subject: [PATCH 11/52] bmk: Add DocumentPart.iter_story_parts() --- .gitignore | 1 + docx/opc/part.py | 8 +++++ docx/parts/document.py | 9 ++++- docx/parts/hdrftr.py | 8 ++--- tests/parts/test_document.py | 70 ++++++++++++++++++++++-------------- 5 files changed, 65 insertions(+), 31 deletions(-) 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/docx/opc/part.py b/docx/opc/part.py index 928d3c183..57aa26612 100644 --- a/docx/opc/part.py +++ b/docx/opc/part.py @@ -74,6 +74,14 @@ 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 NotImplementedError + @classmethod def load(cls, partname, content_type, blob, package): return cls(partname, content_type, blob, package) diff --git a/docx/parts/document.py b/docx/parts/document.py index 45bee1a30..be374c533 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -4,6 +4,8 @@ from __future__ import absolute_import, division, print_function, unicode_literals +from itertools import chain + from docx.document import Document from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.oxml.shape import CT_Inline @@ -97,7 +99,12 @@ def iter_story_parts(self): Story parts include this main document part, headers, footers, footnotes, and endnotes. """ - raise NotImplementedError + 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): """ 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/tests/parts/test_document.py b/tests/parts/test_document.py index d6f0e7731..20fca5306 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -24,6 +24,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_ @@ -80,6 +81,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 +125,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 +227,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 +235,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_ @@ -250,7 +264,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 +272,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 +280,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 +300,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 +312,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 +328,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 +340,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 +348,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") From 9930c27efd93c8185e986db41aa62565c16149b0 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Mon, 24 Dec 2018 11:48:05 -0800 Subject: [PATCH 12/52] opc: add Part.iter_parts_related_by() --- docx/opc/part.py | 5 +- docx/opc/rel.py | 17 ++-- tests/opc/test_part.py | 171 ++++++++++++++++++++++++++--------------- 3 files changed, 121 insertions(+), 72 deletions(-) diff --git a/docx/opc/part.py b/docx/opc/part.py index 57aa26612..de1f6ddda 100644 --- a/docx/opc/part.py +++ b/docx/opc/part.py @@ -80,7 +80,10 @@ def iter_parts_related_by(self, reltypes): *reltypes* must be a container; `set` is convenient but list or other sequence types work fine. """ - return NotImplementedError + return ( + rel.target_part for rel in self.rels.values() + if rel.reltype in reltypes + ) @classmethod def load(cls, partname, content_type, blob, package): diff --git a/docx/opc/rel.py b/docx/opc/rel.py index 7dba2af8e..6c3df427a 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 @@ -132,9 +131,8 @@ def _next_rId(self): 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/tests/opc/test_part.py b/tests/opc/test_part.py index cc60beaae..c9d4793d1 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,7 +482,6 @@ def reltype_2_(self, request): class DescribeXmlPart(object): - def it_can_be_constructed_by_PartFactory( self, partname_, content_type_, blob_, package_, element_, parse_xml_, __init_ ): @@ -489,9 +538,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 +546,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") From b9c2ff30d7a7a4448314c6150d5497e59a31c4b7 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Wed, 26 Dec 2018 14:01:41 -0800 Subject: [PATCH 13/52] pep8: fixes for latest version of flake8 --- docx/enum/base.py | 6 +- docx/image/jpeg.py | 10 +- docx/image/tiff.py | 4 +- docx/opc/rel.py | 2 +- docx/oxml/__init__.py | 211 +++++++++++++++++++----------------- docx/oxml/coreprops.py | 136 +++++++++++------------ docx/oxml/numbering.py | 2 +- docx/oxml/section.py | 57 ++++++---- docx/oxml/simpletypes.py | 2 +- docx/oxml/table.py | 8 +- docx/oxml/xmlchemy.py | 175 +++++++++++++++++------------- docx/package.py | 4 +- features/steps/tabstops.py | 2 +- tests/opc/test_pkgreader.py | 2 +- tests/unitdata.py | 2 +- tests/unitutil/cxml.py | 22 ++-- tests/unitutil/file.py | 2 +- tox.ini | 3 + 18 files changed, 344 insertions(+), 306 deletions(-) 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/rel.py b/docx/opc/rel.py index 6c3df427a..c5bdc9203 100644 --- a/docx/opc/rel.py +++ b/docx/opc/rel.py @@ -124,7 +124,7 @@ 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 diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 093c1b45b..36539340c 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,9 +55,7 @@ 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) # =========================================================================== @@ -65,26 +63,30 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): # =========================================================================== from .shared import CT_DecimalNumber, CT_OnOff, CT_String # noqa + register_element_cls("w:evenAndOddHeaders", CT_OnOff) register_element_cls("w:titlePg", CT_OnOff) from .coreprops import CT_CoreProperties # noqa -register_element_cls('cp:coreProperties', CT_CoreProperties) + +register_element_cls("cp:coreProperties", CT_CoreProperties) from .document import CT_Body, CT_Document # noqa -register_element_cls('w:body', CT_Body) -register_element_cls('w:document', CT_Document) + +register_element_cls("w:body", CT_Body) +register_element_cls("w:document", CT_Document) 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: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 .section import ( # noqa CT_HdrFtr, @@ -94,6 +96,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,6 +107,7 @@ 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 @@ -120,34 +124,36 @@ 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) + +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) + +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 CT_Height, @@ -164,22 +170,23 @@ 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) + +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 CT_Color, @@ -190,37 +197,39 @@ 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) + +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) + +register_element_cls("w:p", CT_P) from .text.parfmt import ( # noqa CT_Ind, @@ -230,19 +239,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) + +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:br", CT_Br) +register_element_cls("w:r", CT_R) +register_element_cls("w:t", CT_Text) 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/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..e71936774 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -20,38 +20,40 @@ 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=()) 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 +61,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 +366,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..671a2d1dc 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -182,7 +182,7 @@ 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' @@ -542,7 +542,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): """ @@ -725,7 +725,7 @@ def _tr_above(self): tr_idx = tr_lst.index(self._tr) if tr_idx == 0: raise ValueError('no tr above topmost tr') - return tr_lst[tr_idx-1] + return tr_lst[tr_idx - 1] @property def _tr_below(self): @@ -736,7 +736,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 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/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/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/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/tox.ini b/tox.ini index 4f75c628e..b94c85c67 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,9 @@ [flake8] exclude = dist,docs,*.egg-info,.git,ref,_scratch,.tox +ignore = + E241 ; multiple spaces after comma + W504 ; line break after binary operator (e.g. '+', 'and') max-line-length = 88 [pytest] From c80383e9df08eddf8720c8b0f96afff636464115 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Wed, 26 Dec 2018 15:33:41 -0800 Subject: [PATCH 14/52] rfctr: upgrade mock helpers to autospec by default --- tests/image/test_tiff.py | 149 ++++++++++++++++++--------------------- tests/opc/test_part.py | 2 + tests/unitutil/mock.py | 10 +-- 3 files changed, 75 insertions(+), 86 deletions(-) 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 c9d4793d1..5fe0a0b99 100644 --- a/tests/opc/test_part.py +++ b/tests/opc/test_part.py @@ -482,6 +482,8 @@ 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_ ): diff --git a/tests/unitutil/mock.py b/tests/unitutil/mock.py index 828382e7e..d838874bf 100644 --- a/tests/unitutil/mock.py +++ b/tests/unitutil/mock.py @@ -70,9 +70,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 +95,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() From 9533c0271eccdccd96c109e622db0c751ca6afd0 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Wed, 26 Dec 2018 16:40:41 -0800 Subject: [PATCH 15/52] bmk: add _PartBookmarkFinder.iter_start_end_pairs() --- docx/bookmark.py | 7 ++++++ tests/test_bookmark.py | 50 +++++++++++++++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/docx/bookmark.py b/docx/bookmark.py index 483303ca1..8bd11ea6a 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -58,7 +58,14 @@ def bookmark_pairs(self): 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.""" raise NotImplementedError diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py index 0395fd56a..8456b84ad 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -2,23 +2,30 @@ """Test suite for the docx.bookmark module.""" -from __future__ import ( - absolute_import, division, print_function, unicode_literals -) +from __future__ import absolute_import, division, print_function, unicode_literals import pytest -from docx.bookmark import Bookmarks, _DocumentBookmarkFinder -from docx.opc.part import Part +from docx.bookmark import Bookmarks, _DocumentBookmarkFinder, _PartBookmarkFinder +from docx.opc.part import Part, XmlPart from docx.parts.document import DocumentPart -from .unitutil.mock import call, class_mock, instance_mock, property_mock +from .unitutil.mock import ( + ANY, + call, + class_mock, + initializer_mock, + instance_mock, + method_mock, + property_mock, +) class DescribeBookmarks(object): def it_knows_how_many_bookmarks_the_document_contains( - self, _finder_prop_, finder_): + self, _finder_prop_, finder_ + ): _finder_prop_.return_value = finder_ finder_.bookmark_pairs = tuple((1, 2) for _ in range(42)) bookmarks = Bookmarks(None) @@ -28,7 +35,8 @@ def it_knows_how_many_bookmarks_the_document_contains( assert count == 42 def it_provides_access_to_its_bookmark_finder_to_help( - self, document_part_, _DocumentBookmarkFinder_, finder_): + self, document_part_, _DocumentBookmarkFinder_, finder_ + ): _DocumentBookmarkFinder_.return_value = finder_ bookmarks = Bookmarks(document_part_) @@ -105,3 +113,29 @@ def _PartBookmarkFinder_(self, request): @pytest.fixture def document_part_(self, request): return instance_mock(request, DocumentPart) + + +class Describe_PartBookmarkFinder(object): + + 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() + assert pairs == _iter_start_end_pairs_.return_value + + # fixture components --------------------------------------------- + + @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 part_(self, request): + return instance_mock(request, XmlPart) From a45bf2041e0006eb9a2f56e3132407e88900c85d Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Thu, 27 Dec 2018 01:19:13 -0800 Subject: [PATCH 16/52] bmk: Add _PartBookmarkFinder._iter_start_end_pairs() --- docx/bookmark.py | 43 +++++++++++++++--- docx/oxml/__init__.py | 33 +++++++++----- docx/oxml/bookmark.py | 18 ++++++++ tests/test_bookmark.py | 100 ++++++++++++++++++++++++++++++----------- 4 files changed, 150 insertions(+), 44 deletions(-) create mode 100644 docx/oxml/bookmark.py diff --git a/docx/bookmark.py b/docx/bookmark.py index 8bd11ea6a..4142c9e4b 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -2,9 +2,7 @@ """Objects related to bookmarks.""" -from __future__ import ( - absolute_import, division, print_function, unicode_literals -) +from __future__ import absolute_import, division, print_function, unicode_literals from itertools import chain @@ -48,10 +46,12 @@ def bookmark_pairs(self): ignored. """ return list( - chain(*( - _PartBookmarkFinder.iter_start_end_pairs(part) - for part in self._document_part.iter_story_parts() - )) + chain( + *( + _PartBookmarkFinder.iter_start_end_pairs(part) + for part in self._document_part.iter_story_parts() + ) + ) ) @@ -68,4 +68,33 @@ def iter_start_end_pairs(cls, part): 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) + + 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. + """ + raise NotImplementedError + + 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. + """ + raise NotImplementedError + + def _name_already_used(self, name): + """Return True if *name* was already encountered, False otherwise.""" raise NotImplementedError diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 36539340c..bd505473f 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -62,22 +62,26 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): # 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 -from .coreprops import CT_CoreProperties # 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 .document import CT_Body, CT_Document # noqa +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 .numbering import CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr # 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) @@ -88,7 +92,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls("w:numbering", CT_Numbering) register_element_cls("w:startOverride", CT_DecimalNumber) -from .section import ( # noqa +from docx.oxml.section import ( # noqa CT_HdrFtr, CT_HdrFtrRef, CT_PageMar, @@ -110,7 +114,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls("w:settings", CT_Settings) -from .shape import ( # noqa +from docx.oxml.shape import ( # noqa CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, @@ -140,7 +144,12 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): 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 +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) @@ -155,7 +164,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls("w:uiPriority", CT_DecimalNumber) register_element_cls("w:unhideWhenUsed", CT_OnOff) -from .table import ( # noqa +from docx.oxml.table import ( # noqa CT_Height, CT_Row, CT_Tbl, @@ -188,7 +197,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls("w:vAlign", CT_VerticalJc) register_element_cls("w:vMerge", CT_VMerge) -from .text.font import ( # noqa +from docx.oxml.text.font import ( # noqa CT_Color, CT_Fonts, CT_Highlight, @@ -227,11 +236,11 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls("w:vertAlign", CT_VerticalAlignRun) register_element_cls("w:webHidden", CT_OnOff) -from .text.paragraph import CT_P # noqa +from docx.oxml.text.paragraph import CT_P # noqa register_element_cls("w:p", CT_P) -from .text.parfmt import ( # noqa +from docx.oxml.text.parfmt import ( # noqa CT_Ind, CT_Jc, CT_PPr, @@ -252,7 +261,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): 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 +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) diff --git a/docx/oxml/bookmark.py b/docx/oxml/bookmark.py new file mode 100644 index 000000000..9385db449 --- /dev/null +++ b/docx/oxml/bookmark.py @@ -0,0 +1,18 @@ +# 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_String +from docx.oxml.xmlchemy import BaseOxmlElement, RequiredAttribute + + +class CT_Bookmark(BaseOxmlElement): + """`w:bookmarkStart` element""" + + name = RequiredAttribute("w:name", ST_String) + + +class CT_MarkupRange(BaseOxmlElement): + """`w:bookmarkEnd` element""" diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py index 8456b84ad..6538765b7 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -10,6 +10,7 @@ from docx.opc.part import Part, XmlPart from docx.parts.document import DocumentPart +from .unitutil.cxml import element from .unitutil.mock import ( ANY, call, @@ -22,10 +23,7 @@ class DescribeBookmarks(object): - - def it_knows_how_many_bookmarks_the_document_contains( - self, _finder_prop_, finder_ - ): + 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) @@ -49,7 +47,7 @@ def it_provides_access_to_its_bookmark_finder_to_help( @pytest.fixture def _DocumentBookmarkFinder_(self, request): - return class_mock(request, 'docx.bookmark._DocumentBookmarkFinder') + return class_mock(request, "docx.bookmark._DocumentBookmarkFinder") @pytest.fixture def document_part_(self, request): @@ -61,40 +59,39 @@ def finder_(self, request): @pytest.fixture def _finder_prop_(self, request): - return property_mock(request, Bookmarks, '_finder') + return property_mock(request, Bookmarks, "_finder") class Describe_DocumentBookmarkFinder(object): - def it_finds_all_the_bookmark_pairs_in_the_document( - self, pairs_fixture, _PartBookmarkFinder_): + 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 _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)]), - ]) + @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) + instance_mock(request, Part, name="Part-%d" % idx) for idx, part_pairs in enumerate(parts_pairs) ] calls = [call(part_) for part_ in mock_parts] @@ -108,7 +105,7 @@ def pairs_fixture(self, request, document_part_, _PartBookmarkFinder_): @pytest.fixture def _PartBookmarkFinder_(self, request): - return class_mock(request, 'docx.bookmark._PartBookmarkFinder') + return class_mock(request, "docx.bookmark._PartBookmarkFinder") @pytest.fixture def document_part_(self, request): @@ -116,6 +113,7 @@ def document_part_(self, request): 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_ @@ -123,9 +121,49 @@ def it_provides_an_iter_start_end_pairs_interface_method( pairs = _PartBookmarkFinder.iter_start_end_pairs(part_) _init_.assert_called_once_with(ANY, part_) - _iter_start_end_pairs_.assert_called_once_with() + _iter_start_end_pairs_.assert_called_once_with(ANY) assert pairs == _iter_start_end_pairs_.return_value + 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]), + ] + # fixture components --------------------------------------------- @pytest.fixture @@ -134,7 +172,19 @@ def _init_(self, request): @pytest.fixture def _iter_start_end_pairs_(self, request): - return method_mock(request, _PartBookmarkFinder, '_iter_start_end_pairs') + 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 part_(self, request): From e67fe2675baccc523a361283c6f24a6a7b1c6faa Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Thu, 27 Dec 2018 12:14:52 -0800 Subject: [PATCH 17/52] bmk: add _PartBookmarkFinder._iter_starts() --- docx/bookmark.py | 13 ++++++++++++- tests/test_bookmark.py | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/docx/bookmark.py b/docx/bookmark.py index 4142c9e4b..6301ef471 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -6,6 +6,7 @@ from itertools import chain +from docx.oxml.ns import qn from docx.shared import lazyproperty @@ -78,13 +79,23 @@ def _iter_start_end_pairs(self): 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. + """ + raise NotImplementedError + 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. """ - raise NotImplementedError + 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*. diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py index 6538765b7..a6cf1dd14 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -164,8 +164,30 @@ def it_iterates_start_end_pairs_to_help( (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]) + ] + # 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) From 13cb94ca396b403d8595e00d5795c2aa22cf109d Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Thu, 27 Dec 2018 12:26:31 -0800 Subject: [PATCH 18/52] bmk: add _PartBookmarkFinder._all_starts_and_ends --- docx/bookmark.py | 2 +- tests/test_bookmark.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docx/bookmark.py b/docx/bookmark.py index 6301ef471..c731fcacc 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -85,7 +85,7 @@ def _all_starts_and_ends(self): Elements appear in document order. """ - raise NotImplementedError + return self._part.element.xpath("//w:bookmarkStart|//w:bookmarkEnd") def _iter_starts(self): """Generate (idx, bookmarkStart) elements in story. diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py index a6cf1dd14..c08c1e2d8 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -124,6 +124,17 @@ def it_provides_an_iter_start_end_pairs_interface_method( _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_ ): From a5ad201374c7629f5490be193e2c29b97e101e07 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Thu, 27 Dec 2018 13:44:54 -0800 Subject: [PATCH 19/52] bmk: add _PartBookmarkFinder._name_already_used() --- docx/bookmark.py | 20 +++++++++- docx/oxml/bookmark.py | 5 ++- tests/test_bookmark.py | 85 ++++++++++++++++++++++++++++++++++++++++++ tox.ini | 4 +- 4 files changed, 109 insertions(+), 5 deletions(-) diff --git a/docx/bookmark.py b/docx/bookmark.py index c731fcacc..61ec51efb 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -104,8 +104,24 @@ def _matching_end(self, bookmarkStart, idx): offset of *bookmarkStart* in the sequence of start and end elements in this story. """ - raise NotImplementedError + 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 *name* was already encountered, False otherwise.""" + """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.""" raise NotImplementedError diff --git a/docx/oxml/bookmark.py b/docx/oxml/bookmark.py index 9385db449..253539f68 100644 --- a/docx/oxml/bookmark.py +++ b/docx/oxml/bookmark.py @@ -4,15 +4,18 @@ from __future__ import absolute_import, division, print_function, unicode_literals -from docx.oxml.simpletypes import ST_String +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/tests/test_bookmark.py b/tests/test_bookmark.py index c08c1e2d8..7bb8bd432 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -193,6 +193,83 @@ def it_iterates_bookmarkStart_elements_to_help(self, _all_starts_and_ends_prop_) (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 + + # 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 @@ -219,6 +296,14 @@ def _matching_end_(self, request): 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/tox.ini b/tox.ini index b94c85c67..3982e7bd0 100644 --- a/tox.ini +++ b/tox.ini @@ -9,10 +9,10 @@ ignore = 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 From 4838fb0c3c7f26ec0a8efe7e0fc069c6a8e6f6b5 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Thu, 27 Dec 2018 13:51:46 -0800 Subject: [PATCH 20/52] bmk: add _PartBookmarkFinder._names_so_far --- docx/bookmark.py | 2 +- tests/test_bookmark.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docx/bookmark.py b/docx/bookmark.py index 61ec51efb..ce42791e3 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -124,4 +124,4 @@ def _name_already_used(self, name): @lazyproperty def _names_so_far(self): """set composed to track bookmark names encountered in document traversal.""" - raise NotImplementedError + return set() diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py index 7bb8bd432..2fdcb23b1 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -218,6 +218,11 @@ def it_knows_whether_a_bookmark_name_was_already_used( 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( From d862e57d60eace1868ce4b8f9cfe7c182ce4c581 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Thu, 27 Dec 2018 14:54:41 -0800 Subject: [PATCH 21/52] parts: add EndnotesPart --- docs/conf.py | 2 ++ docx/__init__.py | 3 +++ docx/oxml/__init__.py | 3 +++ docx/oxml/endnotes.py | 11 +++++++++ docx/parts/endnotes.py | 11 +++++++++ tests/parts/test_endnotes.py | 46 ++++++++++++++++++++++++++++++++++++ 6 files changed, 76 insertions(+) create mode 100644 docx/oxml/endnotes.py create mode 100644 docx/parts/endnotes.py create mode 100644 tests/parts/test_endnotes.py diff --git a/docs/conf.py b/docs/conf.py index 8788e60ac..47e56e7b0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -105,6 +105,8 @@ .. |Emu| replace:: :class:`.Emu` +.. |EndnotesPart| replace:: :class:`.EndnotesPart` + .. |False| replace:: :class:`False` .. |float| replace:: :class:`.float` diff --git a/docx/__init__.py b/docx/__init__.py index 4dae2946b..486ace421 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -12,6 +12,7 @@ from docx.opc.parts.coreprops import CorePropertiesPart from docx.parts.document import DocumentPart +from docx.parts.endnotes import EndnotesPart from docx.parts.hdrftr import FooterPart, HeaderPart from docx.parts.image import ImagePart from docx.parts.numbering import NumberingPart @@ -28,6 +29,7 @@ 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_HEADER] = HeaderPart PartFactory.part_type_for[CT.WML_NUMBERING] = NumberingPart @@ -38,6 +40,7 @@ def part_class_selector(content_type, reltype): CT, CorePropertiesPart, DocumentPart, + EndnotesPart, FooterPart, HeaderPart, NumberingPart, diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index bd505473f..9d0416c20 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -81,6 +81,9 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls("w:body", CT_Body) register_element_cls("w:document", CT_Document) +from docx.oxml.endnotes import CT_Endnotes # noqa +register_element_cls('w:endnotes', CT_Endnotes) + from docx.oxml.numbering import CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr # noqa register_element_cls("w:abstractNumId", CT_DecimalNumber) 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/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/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) From 80a7ff4ffcf49c0189cf505fb649adae1e39ee89 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Thu, 27 Dec 2018 15:06:48 -0800 Subject: [PATCH 22/52] parts: add FootnotesPart --- docs/conf.py | 2 ++ docx/__init__.py | 3 +++ docx/oxml/__init__.py | 5 ++++ docx/oxml/footnotes.py | 11 +++++++++ docx/parts/footnotes.py | 11 +++++++++ tests/parts/test_footnotes.py | 46 +++++++++++++++++++++++++++++++++++ 6 files changed, 78 insertions(+) create mode 100644 docx/oxml/footnotes.py create mode 100644 docx/parts/footnotes.py create mode 100644 tests/parts/test_footnotes.py diff --git a/docs/conf.py b/docs/conf.py index 47e56e7b0..9ce652f2b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -117,6 +117,8 @@ .. |FooterPart| replace:: :class:`.FooterPart` +.. |FootnotesPart| replace:: :class:`.FootnotesPart` + .. |_Header| replace:: :class:`._Header` .. |HeaderPart| replace:: :class:`.HeaderPart` diff --git a/docx/__init__.py b/docx/__init__.py index 486ace421..4660f78dd 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -13,6 +13,7 @@ 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 @@ -31,6 +32,7 @@ def part_class_selector(content_type, reltype): 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 @@ -42,6 +44,7 @@ def part_class_selector(content_type, reltype): DocumentPart, EndnotesPart, FooterPart, + FootnotesPart, HeaderPart, NumberingPart, PartFactory, diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 9d0416c20..1fce04d2a 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -82,8 +82,13 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls("w:document", CT_Document) from docx.oxml.endnotes import CT_Endnotes # noqa + register_element_cls('w:endnotes', CT_Endnotes) +from docx.oxml.footnotes import CT_Footnotes # noqa + +register_element_cls('w:footnotes', CT_Footnotes) + from docx.oxml.numbering import CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr # noqa register_element_cls("w:abstractNumId", CT_DecimalNumber) 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/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/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) From 425245c843224683fa99a9ec9dd51cc011e120e3 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Thu, 27 Dec 2018 19:18:16 -0800 Subject: [PATCH 23/52] bmk: add Bookmarks.__getitem__() and .__iter__() Adding .__iter__() is not strictly required to enable iteration, but it improves the performance of iteration significantly by avoiding the default implementation which would repeatedly parse the bookmark pairs in the document. --- docx/bookmark.py | 27 +++++++++++- features/bmk-bookmarks.feature | 1 - tests/test_bookmark.py | 81 +++++++++++++++++++++++++++++++--- 3 files changed, 99 insertions(+), 10 deletions(-) diff --git a/docx/bookmark.py b/docx/bookmark.py index ce42791e3..f706f4dbe 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -4,18 +4,34 @@ from __future__ import absolute_import, division, print_function, unicode_literals +from collections import Sequence from itertools import chain from docx.oxml.ns import qn from docx.shared import lazyproperty -class Bookmarks(object): - """Sequence of |Bookmark| objects.""" +class Bookmarks(Sequence): + """Sequence of |Bookmark| objects. + + Supports indexed access (including slices), `len()`, and iteration. Iteration will + perform significantly better than repeated indexed access. + """ def __init__(self, document_part): self._document_part = document_part + 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) @@ -25,6 +41,13 @@ def _finder(self): 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 + + class _DocumentBookmarkFinder(object): """Provides access to bookmark oxml elements in an overall document.""" diff --git a/features/bmk-bookmarks.feature b/features/bmk-bookmarks.feature index 842a0be65..72525f9eb 100644 --- a/features/bmk-bookmarks.feature +++ b/features/bmk-bookmarks.feature @@ -4,7 +4,6 @@ Feature: Access a bookmark I need sequence operations on Bookmarks - @wip Scenario: Bookmarks is a sequence Given a Bookmarks object of length 5 as bookmarks Then len(bookmarks) == 5 diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py index 2fdcb23b1..7925e1761 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -6,7 +6,12 @@ import pytest -from docx.bookmark import Bookmarks, _DocumentBookmarkFinder, _PartBookmarkFinder +from docx.bookmark import ( + _Bookmark, + Bookmarks, + _DocumentBookmarkFinder, + _PartBookmarkFinder, +) from docx.opc.part import Part, XmlPart from docx.parts.document import DocumentPart @@ -23,6 +28,60 @@ class DescribeBookmarks(object): + """Unit-test suite for `docx.bookmark.Bookmarks` object.""" + + 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 = 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 = 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 = 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)) @@ -45,6 +104,14 @@ def it_provides_access_to_its_bookmark_finder_to_help( # 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 _DocumentBookmarkFinder_(self, request): return class_mock(request, "docx.bookmark._DocumentBookmarkFinder") @@ -125,9 +192,7 @@ def it_provides_an_iter_start_end_pairs_interface_method( 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)" - ) + body = element("w:body/(w:bookmarkStart,w:p,w:bookmarkEnd,w:p,w:bookmarkStart)") part_.element = body finder = _PartBookmarkFinder(part_) @@ -190,7 +255,9 @@ def it_iterates_bookmarkStart_elements_to_help(self, _all_starts_and_ends_prop_) starts = list(finder._iter_starts()) assert starts == [ - (0, starts_and_ends[0]), (2, starts_and_ends[2]), (4, starts_and_ends[4]) + (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( @@ -279,7 +346,7 @@ def name_used_fixture(self, request): @pytest.fixture def _all_starts_and_ends_prop_(self, request): - return property_mock(request, _PartBookmarkFinder, '_all_starts_and_ends') + return property_mock(request, _PartBookmarkFinder, "_all_starts_and_ends") @pytest.fixture def _init_(self, request): @@ -303,7 +370,7 @@ def _name_already_used_(self, request): @pytest.fixture def _names_so_far_prop_(self, request): - return property_mock(request, _PartBookmarkFinder, '_names_so_far') + return property_mock(request, _PartBookmarkFinder, "_names_so_far") @pytest.fixture def names_so_far_(self, request): From ad444583f4dc5f0fca2535319e7d7a5ab4f3e52c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 27 Dec 2018 21:38:39 -0800 Subject: [PATCH 24/52] test: update tox and fix py3 failures * Sequence is now in `collections.abc` and will be removed from `collections` in 3.8. * zip() returns an iterator in Python 3 and must be wrapped with something like list() or tuple() to be realized. * Remove support for 2.6. * Add tox tests for 3.6 and 3.7. * Update .travis.yml to match tox versions. --- .travis.yml | 5 +++-- docx/bookmark.py | 2 +- docx/compat.py | 7 +++++-- tests/test_bookmark.py | 6 +++--- tests/unitutil/mock.py | 4 +--- tox.ini | 21 ++++++++------------- 6 files changed, 21 insertions(+), 24 deletions(-) diff --git a/.travis.yml b/.travis.yml index 105c42594..eb3075c37 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,9 @@ language: python python: - - "3.6" - - "3.5" - "2.7" + - "3.4" + - "3.5" + - "3.6" # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors install: pip install -r requirements.txt # command to run tests, e.g. python setup.py test diff --git a/docx/bookmark.py b/docx/bookmark.py index f706f4dbe..ece511ba1 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -4,9 +4,9 @@ from __future__ import absolute_import, division, print_function, unicode_literals -from collections import Sequence from itertools import chain +from docx.compat import Sequence from docx.oxml.ns import qn from docx.shared import lazyproperty diff --git a/docx/compat.py b/docx/compat.py index 98ab9051c..548cad55b 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/tests/test_bookmark.py b/tests/test_bookmark.py index 7925e1761..2132695bc 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -36,7 +36,7 @@ def it_provides_access_to_bookmarks_by_index( 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 = zip(bookmarkStarts, bookmarkEnds) + finder_.bookmark_pairs = list(zip(bookmarkStarts, bookmarkEnds)) _Bookmark_.return_value = bookmark_ bookmarks = Bookmarks(None) @@ -51,7 +51,7 @@ def it_provides_access_to_bookmarks_by_slice( 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 = zip(bookmarkStarts, bookmarkEnds) + finder_.bookmark_pairs = list(zip(bookmarkStarts, bookmarkEnds)) _Bookmark_.return_value = bookmark_ bookmarks = Bookmarks(None) @@ -69,7 +69,7 @@ def it_can_iterate_its_bookmarks( 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 = zip(bookmarkStarts, bookmarkEnds) + finder_.bookmark_pairs = list(zip(bookmarkStarts, bookmarkEnds)) _Bookmark_.return_value = bookmark_ bookmarks = Bookmarks(None) diff --git a/tests/unitutil/mock.py b/tests/unitutil/mock.py index d838874bf..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 diff --git a/tox.ini b/tox.ini index 3982e7bd0..712cdc7e3 100644 --- a/tox.ini +++ b/tox.ini @@ -15,32 +15,27 @@ python_functions = it_ they_ but_ and_it_ testpaths = tests [tox] -envlist = py26, py27, py34, py35, py36 +envlist = py27, py37 -[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 From 794af7de2fbc13da317273d890fca87cf48c7f2c Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Sat, 29 Dec 2018 23:08:55 +0100 Subject: [PATCH 25/52] acpt: Add scenario for named bookmark access --- features/bmk-bookmarks.feature | 13 +++++++++++++ features/steps/bookmarks.py | 31 +++++++++++++++++++------------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/features/bmk-bookmarks.feature b/features/bmk-bookmarks.feature index 72525f9eb..9182c7b26 100644 --- a/features/bmk-bookmarks.feature +++ b/features/bmk-bookmarks.feature @@ -9,3 +9,16 @@ Feature: Access a bookmark Then len(bookmarks) == 5 And bookmarks[1] is a _Bookmark object And iterating bookmarks produces 5 _Bookmark objects + + @wip + 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/steps/bookmarks.py b/features/steps/bookmarks.py index 8c8197fc8..7aa7e7cf2 100644 --- a/features/steps/bookmarks.py +++ b/features/steps/bookmarks.py @@ -2,9 +2,7 @@ """Step implementations for bookmark-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 @@ -15,31 +13,40 @@ # given =================================================== -@given('a Bookmarks object of length 5 as bookmarks') + +@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')) + document = Document(test_docx("bmk-bookmarks")) context.bookmarks = document.bookmarks # then ===================================================== -@then('bookmarks[{idx}] is a _Bookmark object') + +@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' + expected = "_Bookmark" actual = item.__class__.__name__ - assert actual == expected, 'bookmarks[%s] is a %s object' % (idx, actual) + assert actual == expected, "bookmarks[%s] is a %s object" % (idx, actual) -@then('iterating bookmarks produces {n} _Bookmark objects') +@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) + assert all(item.__class__.__name__ == "_Bookmark" for item in items) -@then('len(bookmarks) == {count}') +@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 + assert actual == expected, "len(bookmarks) == %s" % actual From 745f5c8a1806094d10d2c40c1247ea7c3a72c1bd Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Sun, 6 Jan 2019 12:05:07 +0100 Subject: [PATCH 26/52] bmk: Add Bookmarks.get() --- docx/bookmark.py | 16 ++++++++++++++-- tests/test_bookmark.py | 29 +++++++++++++++++++++++++++++ tox.ini | 2 +- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docx/bookmark.py b/docx/bookmark.py index ece511ba1..ef2e3c719 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -40,6 +40,13 @@ def _finder(self): """_DocumentBookmarkFinder instance for this document.""" return _DocumentBookmarkFinder(self._document_part) + def get(self, name): + """Get bookmark based on its name.""" + for bookmark in self: + if bookmark.name == name: + return bookmark + raise KeyError("Requested bookmark not found.") + class _Bookmark(object): """Proxy for a (w:bookmarkStart, w:bookmarkEnd) element pair.""" @@ -47,6 +54,11 @@ class _Bookmark(object): def __init__(self, bookmark_pair): self._bookmarkStart, self._bookmarkEnd = bookmark_pair + @property + def name(self): + """Provides access to the bookmark name.""" + raise NotImplementedError + class _DocumentBookmarkFinder(object): """Provides access to bookmark oxml elements in an overall document.""" @@ -127,9 +139,9 @@ def _matching_end(self, bookmarkStart, idx): offset of *bookmarkStart* in the sequence of start and end elements in this story. """ - for element in self._all_starts_and_ends[idx + 1:]: + for element in self._all_starts_and_ends[idx + 1 :]: # ---skip bookmark starts--- - if element.tag == qn('w:bookmarkStart'): + if element.tag == qn("w:bookmarkStart"): continue bookmarkEnd = element if bookmarkEnd.id == bookmarkStart.id: diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py index 2132695bc..a517eebc3 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -91,6 +91,27 @@ def it_knows_how_many_bookmarks_the_document_contains(self, _finder_prop_, finde 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_provides_access_to_its_bookmark_finder_to_help( self, document_part_, _DocumentBookmarkFinder_, finder_ ): @@ -112,6 +133,10 @@ def _Bookmark_(self, request): 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") @@ -128,6 +153,10 @@ def finder_(self, request): 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_DocumentBookmarkFinder(object): def it_finds_all_the_bookmark_pairs_in_the_document( diff --git a/tox.ini b/tox.ini index 712cdc7e3..110e11a1e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,9 @@ # # Configuration for tox and pytest - [flake8] exclude = dist,docs,*.egg-info,.git,ref,_scratch,.tox ignore = + E203 ; whitespace before ':' E241 ; multiple spaces after comma W504 ; line break after binary operator (e.g. '+', 'and') max-line-length = 88 From 06c6e8b09c85c1ee3e142810dcaded53d9cb616e Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Sat, 13 Jul 2019 17:22:45 -0700 Subject: [PATCH 27/52] bmk: Add _Bookmark.name --- docx/bookmark.py | 7 ++++++- tests/test_bookmark.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docx/bookmark.py b/docx/bookmark.py index ef2e3c719..24ed6facc 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -54,10 +54,15 @@ class _Bookmark(object): def __init__(self, bookmark_pair): self._bookmarkStart, self._bookmarkEnd = bookmark_pair + @property + def id(self): + """Provides access to the bookmark id.""" + raise NotImplementedError + @property def name(self): """Provides access to the bookmark name.""" - raise NotImplementedError + return self._bookmarkStart.name class _DocumentBookmarkFinder(object): diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py index a517eebc3..96ba458b0 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -158,6 +158,18 @@ 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_its_name(self): + bookmarkStart = element("w:bookmarkStart{w:name=bmk-0}") + bookmarkEnd = element("w:bookmarkEnd") + + bookmark = _Bookmark((bookmarkStart, bookmarkEnd)) + + assert bookmark.name == "bmk-0" + + class Describe_DocumentBookmarkFinder(object): def it_finds_all_the_bookmark_pairs_in_the_document( self, pairs_fixture, _PartBookmarkFinder_ From 13c3dacf8cac28078c274ebf0c2654630d75341d Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Sun, 6 Jan 2019 12:07:44 +0100 Subject: [PATCH 28/52] bmk: Add _Bookmark.id --- docx/bookmark.py | 2 +- features/bmk-bookmarks.feature | 1 - tests/test_bookmark.py | 8 ++++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docx/bookmark.py b/docx/bookmark.py index 24ed6facc..f470dc919 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -57,7 +57,7 @@ def __init__(self, bookmark_pair): @property def id(self): """Provides access to the bookmark id.""" - raise NotImplementedError + return self._bookmarkStart.id @property def name(self): diff --git a/features/bmk-bookmarks.feature b/features/bmk-bookmarks.feature index 9182c7b26..03de53006 100644 --- a/features/bmk-bookmarks.feature +++ b/features/bmk-bookmarks.feature @@ -10,7 +10,6 @@ Feature: Access a bookmark And bookmarks[1] is a _Bookmark object And iterating bookmarks produces 5 _Bookmark objects - @wip Scenario Outline: Bookmarks.get(bookmark_name) Given a Bookmarks object of length 5 as bookmarks Then bookmarks.get() returns bookmark named "" with id diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py index 96ba458b0..c779235f2 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -161,6 +161,14 @@ def _iter_(self, request): class Describe_Bookmark(object): """Unit-test suite for `docx.bookmark._Bookmark` object.""" + 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_its_name(self): bookmarkStart = element("w:bookmarkStart{w:name=bmk-0}") bookmarkEnd = element("w:bookmarkEnd") From 4c93f5daa04689be58cd926c4647054c3c384ec8 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Jul 2019 17:47:30 -0700 Subject: [PATCH 29/52] acpt: add Document.start_bookmark() scenario --- features/doc-document.feature | 11 +++ features/steps/bookmarks.py | 12 ++++ features/steps/document.py | 123 +++++++++++++++++----------------- 3 files changed, 86 insertions(+), 60 deletions(-) create mode 100644 features/doc-document.feature diff --git a/features/doc-document.feature b/features/doc-document.feature new file mode 100644 index 000000000..c34238235 --- /dev/null +++ b/features/doc-document.feature @@ -0,0 +1,11 @@ +Feature: Document properties and methods + In order manipulate a Word document + As a developer using python-docx + I need properties and methods on the Document object + + @wip + 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 diff --git a/features/steps/bookmarks.py b/features/steps/bookmarks.py index 7aa7e7cf2..88b7771ea 100644 --- a/features/steps/bookmarks.py +++ b/features/steps/bookmarks.py @@ -23,6 +23,18 @@ def given_a_Bookmarks_object_of_length_5_as_bookmarks(context): # 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) diff --git a/features/steps/document.py b/features/steps/document.py index f99a3a6cf..ce72b4a0f 100644 --- a/features/steps/document.py +++ b/features/steps/document.py @@ -22,44 +22,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') +@given("a Document object as document") def given_a_Document_object_as_document(context): - context.document = Document(test_docx('doc-default')) + 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) @@ -71,55 +72,53 @@ def given_a_single_section_Document_object_with_headers_and_footers(context): # 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() @@ -129,39 +128,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 @@ -177,21 +181,22 @@ def when_I_execute_section_eq_document_add_section(context): # then ==================================================== -@then('document.bookmarks is a Bookmarks object') + +@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 + expected = "Bookmarks" + assert actual == expected, "document.bookmarks is a %s object" % actual -@then('document.inline_shapes is an InlineShapes object') +@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 @@ -201,20 +206,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 @@ -224,7 +229,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) @@ -233,12 +238,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 @@ -247,16 +252,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 @@ -264,7 +269,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 @@ -273,10 +278,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 From 4acb2e5a49f6458fc13735c66e94f5c2a4df506e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Jul 2019 18:52:58 -0700 Subject: [PATCH 30/52] bmk: add Document.start_bookmark() --- docx/blkcntnr.py | 11 +++- docx/document.py | 7 +++ tests/test_document.py | 122 ++++++++++++++++++++++------------------- 3 files changed, 84 insertions(+), 56 deletions(-) diff --git a/docx/blkcntnr.py b/docx/blkcntnr.py index a80903e52..39e96556f 100644 --- a/docx/blkcntnr.py +++ b/docx/blkcntnr.py @@ -25,7 +25,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,6 +46,7 @@ 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) @@ -58,6 +59,13 @@ def paragraphs(self): """ return [Paragraph(p, self) for p in self._element.p_lst] + def start_bookmark(self, name): + """Return _Bookmark object identified by `name`. + + The returned bookmark is anchored at the end of this block-item container. + """ + raise NotImplementedError + @property def tables(self): """ @@ -65,6 +73,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): diff --git a/docx/document.py b/docx/document.py index f511bb4c7..f54126faa 100644 --- a/docx/document.py +++ b/docx/document.py @@ -159,6 +159,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): """ diff --git a/tests/test_document.py b/tests/test_document.py index 9e05b2e22..ff89d106d 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -6,7 +6,7 @@ import pytest -from docx.bookmark import Bookmarks +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 @@ -26,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 @@ -78,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_ @@ -95,7 +96,8 @@ def it_can_save_the_document_to_a_file(self, save_fixture): document._part.save.assert_called_once_with(file_) def it_provides_access_to_its_bookmarks( - self, document_part_, Bookmarks_, bookmarks_): + self, document_part_, Bookmarks_, bookmarks_ + ): Bookmarks_.return_value = bookmarks_ document = Document(None, document_part_) @@ -119,7 +121,7 @@ def it_provides_access_to_its_paragraphs(self, paragraphs_fixture): assert paragraphs is paragraphs_ 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_) @@ -132,6 +134,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_ @@ -159,57 +171,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_ @@ -225,7 +232,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_ @@ -243,9 +250,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 @@ -256,7 +263,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 @@ -272,36 +279,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') + 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 body_prop_(self, request, body_): - return property_mock(request, Document, '_body') + def _body_prop_(self, request, body_): + return property_mock(request, Document, "_body") + + @pytest.fixture + def bookmark_(self, request): + return instance_mock(request, _Bookmark) @pytest.fixture def Bookmarks_(self, request): - return class_mock(request, 'docx.document.Bookmarks') + return class_mock(request, "docx.document.Bookmarks") @pytest.fixture def bookmarks_(self, request): @@ -337,7 +348,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): @@ -345,7 +356,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): @@ -353,7 +364,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): @@ -365,7 +376,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): @@ -373,7 +384,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() @@ -382,12 +392,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) From aaf7987f7df139a91c2fd75fef9796ce0ab79ab8 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 2 Sep 2019 14:47:56 -0700 Subject: [PATCH 31/52] blkcntnr: add BlockItemContainer.start_bookmark() --- docx/blkcntnr.py | 21 +++- docx/bookmark.py | 9 ++ docx/oxml/document.py | 30 +++-- docx/oxml/section.py | 13 +++ docx/oxml/table.py | 249 ++++++++++++++++++++++++++--------------- tests/test_blkcntnr.py | 138 ++++++++++++++++++----- 6 files changed, 327 insertions(+), 133 deletions(-) diff --git a/docx/blkcntnr.py b/docx/blkcntnr.py index 39e96556f..7e032e7c2 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 @@ -60,11 +61,18 @@ def paragraphs(self): return [Paragraph(p, self) for p in self._element.p_lst] def start_bookmark(self, name): - """Return _Bookmark object identified by `name`. + """Return newly-added |_Bookmark| object identified by `name`. - The returned bookmark is anchored at the end of this block-item container. + 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. """ - raise NotImplementedError + 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): @@ -82,3 +90,8 @@ def _add_paragraph(self): container. """ return Paragraph(self._element.add_p(), self) + + @lazyproperty + def _bookmarks(self): + """Global |Bookmarks| object for overall document.""" + raise NotImplementedError diff --git a/docx/bookmark.py b/docx/bookmark.py index f470dc919..be73f58f7 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -21,6 +21,10 @@ class Bookmarks(Sequence): 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`.""" + raise NotImplementedError + def __getitem__(self, idx): """Supports indexed and sliced access.""" bookmark_pairs = self._finder.bookmark_pairs @@ -47,6 +51,11 @@ def get(self, 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.""" + raise NotImplementedError + class _Bookmark(object): """Proxy for a (w:bookmarkStart, w:bookmarkEnd) element pair.""" diff --git a/docx/oxml/document.py b/docx/oxml/document.py index 4211b8ed1..0d0e4d774 100644 --- a/docx/oxml/document.py +++ b/docx/oxml/document.py @@ -12,7 +12,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 +21,28 @@ 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",)) + sectPr = ZeroOrOne("w:sectPr", successors=()) + + 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/section.py b/docx/oxml/section.py index e71936774..39dfbb684 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -22,6 +22,19 @@ class CT_HdrFtr(BaseOxmlElement): p = ZeroOrMore("w:p", successors=()) tbl = ZeroOrMore("w:tbl", successors=()) + bookmarkStart = ZeroOrMore("w:bookmarkStart", successors=()) + + 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): diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 671a2d1dc..e9ced7c3d 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): @@ -184,52 +194,50 @@ def tblStyle_val(self, styleId): def _tbl_xml(cls, rows, cols, width): 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,35 @@ 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=()) + + 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 +461,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 +490,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 +567,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 @@ -591,7 +627,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 +643,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 +656,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 +708,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 +727,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 +751,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,7 +762,7 @@ 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') + raise ValueError("no tr above topmost tr") return tr_lst[tr_idx - 1] @property @@ -752,16 +790,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 +891,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 +949,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/tests/test_blkcntnr.py b/tests/test_blkcntnr.py index 5bc9bab3f..f281feaee 100644 --- a/tests/test_blkcntnr.py +++ b/tests/test_blkcntnr.py @@ -7,16 +7,25 @@ import pytest from docx.blkcntnr import BlockItemContainer +from docx.bookmark import _Bookmark, Bookmarks 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 +41,19 @@ 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_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 +62,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): @@ -71,12 +117,7 @@ def it_adds_a_paragraph_to_help(self, _add_paragraph_fixture): # 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 +126,60 @@ 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=[ + ("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,7 +189,23 @@ 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): From e37e45b320f2455a96b153fcb955da1005e59c37 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 2 Sep 2019 16:01:54 -0700 Subject: [PATCH 32/52] blkcntnr: add BlockItemContainer._bookmarks --- docs/conf.py | 2 ++ docx/blkcntnr.py | 2 +- docx/document.py | 3 +-- docx/parts/document.py | 6 ++++++ docx/parts/story.py | 7 ++++++- tests/parts/test_document.py | 18 ++++++++++++++++++ tests/test_blkcntnr.py | 26 ++++++++++++++++++++++++++ tests/test_document.py | 11 ++--------- 8 files changed, 62 insertions(+), 13 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 9ce652f2b..4e8337899 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -93,6 +93,8 @@ .. |_Columns| replace:: :class:`._Columns` +.. |CommentsPart| replace:: :class:`.CommentsPart` + .. |CoreProperties| replace:: :class:`.CoreProperties` .. |datetime| replace:: :class:`.datetime.datetime` diff --git a/docx/blkcntnr.py b/docx/blkcntnr.py index 7e032e7c2..bdd07cb78 100644 --- a/docx/blkcntnr.py +++ b/docx/blkcntnr.py @@ -94,4 +94,4 @@ def _add_paragraph(self): @lazyproperty def _bookmarks(self): """Global |Bookmarks| object for overall document.""" - raise NotImplementedError + return self.part.bookmarks diff --git a/docx/document.py b/docx/document.py index f54126faa..c1be16809 100644 --- a/docx/document.py +++ b/docx/document.py @@ -6,7 +6,6 @@ from docx.blkcntnr import BlockItemContainer -from docx.bookmark import Bookmarks from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK from docx.section import Section, Sections @@ -103,7 +102,7 @@ def bookmarks(self): footers, footnotes or endnotes. This collection contains all bookmarks defined in any of these parts. """ - return Bookmarks(self._part) + return self._part.bookmarks @property def core_properties(self): diff --git a/docx/parts/document.py b/docx/parts/document.py index be374c533..796bae89b 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -6,6 +6,7 @@ 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 @@ -39,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): """ diff --git a/docx/parts/story.py b/docx/parts/story.py index 129b8f1cc..d476caad5 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.""" + raise NotImplementedError + 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/tests/parts/test_document.py b/tests/parts/test_document.py index 20fca5306..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 @@ -50,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) @@ -254,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) diff --git a/tests/test_blkcntnr.py b/tests/test_blkcntnr.py index f281feaee..a8efeb352 100644 --- a/tests/test_blkcntnr.py +++ b/tests/test_blkcntnr.py @@ -8,6 +8,8 @@ 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 @@ -110,11 +112,25 @@ 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")]) @@ -138,6 +154,12 @@ def add_table_fixture(self): expected_xml = snippet_seq("new-tbl")[0] return blkcntnr, rows, cols, width, expected_xml + @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=[ ("w:body", 0), @@ -210,3 +232,7 @@ def _bookmarks_prop_(self, request): @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_document.py b/tests/test_document.py index ff89d106d..b316f4285 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -95,15 +95,12 @@ def it_can_save_the_document_to_a_file(self, save_fixture): document.save(file_) document._part.save.assert_called_once_with(file_) - def it_provides_access_to_its_bookmarks( - self, document_part_, Bookmarks_, bookmarks_ - ): - Bookmarks_.return_value = bookmarks_ + def it_provides_access_to_its_bookmarks(self, document_part_, bookmarks_): + document_part_.bookmarks = bookmarks_ document = Document(None, document_part_) bookmarks = document.bookmarks - Bookmarks_.assert_called_once_with(document_part_) assert bookmarks is bookmarks_ def it_provides_access_to_its_core_properties(self, core_props_fixture): @@ -310,10 +307,6 @@ def _body_prop_(self, request, body_): def bookmark_(self, request): return instance_mock(request, _Bookmark) - @pytest.fixture - def Bookmarks_(self, request): - return class_mock(request, "docx.document.Bookmarks") - @pytest.fixture def bookmarks_(self, request): return instance_mock(request, Bookmarks) From f1d73960f2822bdc94560df0d79ba65756d3482d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 2 Sep 2019 16:45:44 -0700 Subject: [PATCH 33/52] bmk: add Bookmarks.__contains__() --- docx/bookmark.py | 18 ++++++++++++++---- tests/test_bookmark.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docx/bookmark.py b/docx/bookmark.py index be73f58f7..fd3b8763a 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -14,8 +14,12 @@ class Bookmarks(Sequence): """Sequence of |Bookmark| objects. - Supports indexed access (including slices), `len()`, and iteration. Iteration will - perform significantly better than repeated indexed access. + 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): @@ -23,7 +27,10 @@ def __init__(self, document_part): def __contains__(self, name): """Supports `in` operator to test for presence of bookmark by `name`.""" - raise NotImplementedError + for bookmark in self: + if bookmark.name == name: + return True + return False def __getitem__(self, idx): """Supports indexed and sliced access.""" @@ -45,7 +52,10 @@ def _finder(self): return _DocumentBookmarkFinder(self._document_part) def get(self, name): - """Get bookmark based on its 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 diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py index c779235f2..a55a03850 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -30,6 +30,15 @@ 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_ ): @@ -123,6 +132,25 @@ def it_provides_access_to_its_bookmark_finder_to_help( _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 + # fixture components --------------------------------------------- @pytest.fixture From 4b46473308245ba7846f7c0a2f4e36573480ab69 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 2 Sep 2019 18:02:53 -0700 Subject: [PATCH 34/52] bmk: add Bookmarks.next_id --- docx/bookmark.py | 15 +++++++++------ features/doc-document.feature | 3 +-- tests/test_bookmark.py | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/docx/bookmark.py b/docx/bookmark.py index fd3b8763a..b6320cb83 100644 --- a/docx/bookmark.py +++ b/docx/bookmark.py @@ -46,11 +46,6 @@ def __iter__(self): def __len__(self): return len(self._finder.bookmark_pairs) - @lazyproperty - def _finder(self): - """_DocumentBookmarkFinder instance for this document.""" - return _DocumentBookmarkFinder(self._document_part) - def get(self, name): """Get bookmark based on its name. @@ -64,7 +59,15 @@ def get(self, name): @property def next_id(self): """Return the next available int bookmark-id, unique in document-wide scope.""" - raise NotImplementedError + 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): diff --git a/features/doc-document.feature b/features/doc-document.feature index c34238235..be5ab2af4 100644 --- a/features/doc-document.feature +++ b/features/doc-document.feature @@ -1,9 +1,8 @@ Feature: Document properties and methods - In order manipulate a Word document + In order to manipulate a Word document As a developer using python-docx I need properties and methods on the Document object - @wip Scenario: Document.start_bookmark() Given a Document object as document When I assign bookmark = document.start_bookmark("Target") diff --git a/tests/test_bookmark.py b/tests/test_bookmark.py index a55a03850..c2d284364 100644 --- a/tests/test_bookmark.py +++ b/tests/test_bookmark.py @@ -121,6 +121,15 @@ def but_it_raises_KeyError_when_no_bookmark_by_that_name(self, bookmark_, _iter_ 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_ ): @@ -151,6 +160,14 @@ def contains_fixture(self, request): 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 From c46483bbe5a7c5981703d12f6cf8489c7be673a9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 2 Sep 2019 18:46:37 -0700 Subject: [PATCH 35/52] rfctr: modernize selected feature files --- features/cel-add-table.feature | 12 - features/cel-text.feature | 9 - features/steps/cell.py | 54 --- features/steps/hdrftr.py | 11 +- features/steps/table.py | 353 +++++++++--------- ...bl-cell-props.feature => tbl-cell.feature} | 0 6 files changed, 186 insertions(+), 253 deletions(-) delete mode 100644 features/cel-add-table.feature delete mode 100644 features/cel-text.feature delete mode 100644 features/steps/cell.py rename features/{tbl-cell-props.feature => tbl-cell.feature} (100%) 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/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/hdrftr.py b/features/steps/hdrftr.py index 786673dbd..4f0e3b915 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,13 @@ def given_the_next_Header_object_with_no_header_definition(context): # when ===================================================== -@when("I assign \"Normal\" to footer.paragraphs[0].style") + +@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 +80,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 +88,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 +116,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/tbl-cell-props.feature b/features/tbl-cell.feature similarity index 100% rename from features/tbl-cell-props.feature rename to features/tbl-cell.feature From bc6218977dc44feea212156b9cfeeabfa9a6ab6a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 2 Sep 2019 21:36:10 -0700 Subject: [PATCH 36/52] acpt: add remaining start_bookmark() scenarios --- features/hdr-header-footer.feature | 26 ++++++++++++++++++++++++++ features/steps/hdrftr.py | 12 ++++++++++++ features/tbl-cell.feature | 25 +++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/features/hdr-header-footer.feature b/features/hdr-header-footer.feature index eb2bb00d6..ccc703179 100644 --- a/features/hdr-header-footer.feature +++ b/features/hdr-header-footer.feature @@ -46,6 +46,19 @@ Feature: Header and footer behaviors Then I can't detect the image but no exception is raised + @wip + 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 +99,16 @@ 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 + + + @wip + 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/hdrftr.py b/features/steps/hdrftr.py index 4f0e3b915..e6685db20 100644 --- a/features/steps/hdrftr.py +++ b/features/steps/hdrftr.py @@ -53,6 +53,18 @@ def given_the_next_Header_object_with_no_header_definition(context): # when ===================================================== +@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" diff --git a/features/tbl-cell.feature b/features/tbl-cell.feature index 609d2f442..a06b960b7 100644 --- a/features/tbl-cell.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 From e14010811dcc4c7a41ed6fd2697dadd159c2fd64 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 2 Sep 2019 21:45:32 -0700 Subject: [PATCH 37/52] parts: add BaseStoryPart.bookmarks --- docx/parts/story.py | 2 +- features/hdr-header-footer.feature | 2 -- tests/parts/test_story.py | 17 +++++++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docx/parts/story.py b/docx/parts/story.py index d476caad5..6a2692f6d 100644 --- a/docx/parts/story.py +++ b/docx/parts/story.py @@ -21,7 +21,7 @@ class BaseStoryPart(XmlPart): @lazyproperty def bookmarks(self): """Global |Bookmarks| object for this docx package.""" - raise NotImplementedError + return self._document_part.bookmarks def get_or_add_image(self, image_descriptor): """Return (rId, image) pair for image identified by *image_descriptor*. diff --git a/features/hdr-header-footer.feature b/features/hdr-header-footer.feature index ccc703179..557973258 100644 --- a/features/hdr-header-footer.feature +++ b/features/hdr-header-footer.feature @@ -46,7 +46,6 @@ Feature: Header and footer behaviors Then I can't detect the image but no exception is raised - @wip Scenario Outline: _Header.start_bookmark() Given a _Header object header definition as header When I assign bookmark = header.start_bookmark("Target") @@ -101,7 +100,6 @@ Feature: Header and footer behaviors Then I can't detect the image but no exception is raised - @wip Scenario Outline: _Footer.start_bookmark() Given a _Footer object footer definition as footer When I assign bookmark = footer.start_bookmark("Target") 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) From ff531369d6e00eeddf631a81cf38ded6e068796c Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Tue, 3 Sep 2019 19:44:33 +0200 Subject: [PATCH 38/52] acpt: add end_bookmark() scenarios --- docx/document.py | 4 ++++ features/doc-document.feature | 8 ++++++++ features/hdr-header-footer.feature | 27 +++++++++++++++++++++++++++ features/steps/document.py | 12 ++++++++++++ features/steps/hdrftr.py | 24 ++++++++++++++++++++++++ features/steps/table.py | 12 ++++++++++++ features/tbl-cell.feature | 8 ++++++++ 7 files changed, 95 insertions(+) diff --git a/docx/document.py b/docx/document.py index c1be16809..8d41447b4 100644 --- a/docx/document.py +++ b/docx/document.py @@ -112,6 +112,10 @@ def core_properties(self): """ return self._part.core_properties + def end_bookmark(self, bookmark): + """Closes supplied bookmark at the end of the document.""" + raise NotImplementedError + @property def inline_shapes(self): """ diff --git a/features/doc-document.feature b/features/doc-document.feature index be5ab2af4..1e925e864 100644 --- a/features/doc-document.feature +++ b/features/doc-document.feature @@ -8,3 +8,11 @@ Feature: Document properties and methods 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 + When I assign bookmark = document.start_bookmark("Target") + And I end bookmark by calling document.end_bookmark(bookmark) + And I assign bookmark = document.bookmarks.get("Target") + Then bookmark.name == "Target" diff --git a/features/hdr-header-footer.feature b/features/hdr-header-footer.feature index 557973258..a99943866 100644 --- a/features/hdr-header-footer.feature +++ b/features/hdr-header-footer.feature @@ -58,6 +58,20 @@ Feature: Header and footer behaviors | with no | + @wip + Scenario Outline: _Header.end_bookmark() + Given a _Header object header definition as header + When I assign bookmark = header.start_bookmark("Target") + And I end bookmark by calling header.end_bookmark(bookmark) + And I assign bookmark = header._bookmarks.get("Target") + Then bookmark.name == "Target" + + Examples: _Footer 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 @@ -110,3 +124,16 @@ Feature: Header and footer behaviors | with-or-no | | with a | | with no | + + @wip + Scenario Outline: _Footer.end_bookmark() + Given a _Footer object footer definition as footer + When I assign bookmark = footer.start_bookmark("Target") + And I end bookmark by calling footer.end_bookmark(bookmark) + And I assign bookmark = footer._bookmarks.get("Target") + Then bookmark.name == "Target" + + Examples: _Footer definition states + | with-or-no | + | with a | + | with no | \ No newline at end of file diff --git a/features/steps/document.py b/features/steps/document.py index ce72b4a0f..c79a525f3 100644 --- a/features/steps/document.py +++ b/features/steps/document.py @@ -159,6 +159,12 @@ def when_I_add_an_even_page_section_to_the_document(context): context.section = context.document.add_section(WD_SECTION.EVEN_PAGE) +@when('I assign bookmark = document.bookmarks.get("Target")') +def when_I_assign_bookmark_eq_document_bookmarks_get_target(context): + document = context.document + context.bookmark = document.bookmarks.get("Target") + + @when('I assign bookmark = document.start_bookmark("Target")') def when_I_assign_bookmark_eq_document_start_bookmark(context): document = context.document @@ -174,6 +180,12 @@ def when_I_change_the_new_section_layout_to_landscape(context): section.page_height = new_height +@when("I end bookmark by calling document.end_bookmark(bookmark)") +def when_I_end_bookmark_by_calling_document_end_bookmark_with_bookmark(context): + document = context.document + document.end_bookmark(context.bookmark) + + @when("I execute section = document.add_section()") def when_I_execute_section_eq_document_add_section(context): context.section = context.document.add_section() diff --git a/features/steps/hdrftr.py b/features/steps/hdrftr.py index e6685db20..775de4a55 100644 --- a/features/steps/hdrftr.py +++ b/features/steps/hdrftr.py @@ -53,6 +53,18 @@ def given_the_next_Header_object_with_no_header_definition(context): # when ===================================================== +@when('I assign bookmark = footer._bookmarks.get("Target")') +def when_I_assign_bookmark_eq_footer_bookmarks_get_target(context): + footer = context.footer + context.bookmark = footer._bookmarks.get("Target") + + +@when('I assign bookmark = header._bookmarks.get("Target")') +def when_I_assign_bookmark_eq_header_bookmarks_get_target(context): + header = context.header + context.bookmark = header._bookmarks.get("Target") + + @when('I assign bookmark = footer.start_bookmark("Target")') def when_I_assign_bookmark_eq_footer_start_bookmark(context): footer = context.footer @@ -90,6 +102,18 @@ def when_I_call_run_add_picture(context): context.run.add_picture(test_file("test.png")) +@when("I end bookmark by calling footer.end_bookmark(bookmark)") +def when_I_end_bookmark_by_calling_footer_end_bookmark_with_bookmark(context): + footer = context.footer + footer.end_bookmark(context.bookmark) + + +@when("I end bookmark by calling header.end_bookmark(bookmark)") +def when_I_end_bookmark_by_calling_header_end_bookmark_with_bookmark(context): + header = context.header + header.end_bookmark(context.bookmark) + + # then ===================================================== diff --git a/features/steps/table.py b/features/steps/table.py index e090aaa00..2b87b8666 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -180,6 +180,12 @@ def when_assign_string_to_cell_text_attribute(context): context.expected_text = text +@when('I assign bookmark = cell.bookmarks.get("Target")') +def when_I_assign_bookmark_eq_cell_bookmarks_get_target(context): + cell = context.cell + context.bookmark = cell._bookmarks.get("Target") + + @when('I assign bookmark = cell.start_bookmark("Target")') def when_I_assign_bookmark_eq_cell_start_bookmark(context): cell = context.cell @@ -233,6 +239,12 @@ def when_assign_value_to_table_table_direction(context, value): context.table_.table_direction = new_value +@when("I end bookmark by calling cell.end_bookmark(bookmark)") +def when_I_end_bookmark_by_calling_cell_end_bookmark_with_bookmark(context): + cell = context.cell + cell.end_bookmark(context.bookmark) + + @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): diff --git a/features/tbl-cell.feature b/features/tbl-cell.feature index a06b960b7..cba50cb63 100644 --- a/features/tbl-cell.feature +++ b/features/tbl-cell.feature @@ -3,6 +3,14 @@ Feature: _Cell properties and methods As a developer using python-docx I need properties and methods on a _Cell object + @wip + Scenario: _Cell.end_bookmark() + Given a _Cell object as cell + When I assign bookmark = cell.start_bookmark("Target") + And I end bookmark by calling cell.end_bookmark(bookmark) + And I assign bookmark = cell.bookmarks.get("Target") + Then bookmark.name == "Target" + Scenario: _Cell.start_bookmark() Given a _Cell object as cell From 1160d9e4ab87868f8bb481fd5ba64030fea041df Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Tue, 3 Sep 2019 19:52:21 +0200 Subject: [PATCH 39/52] bmk: add Document.end_bookmark() --- docx/blkcntnr.py | 4 ++++ docx/document.py | 2 +- tests/test_document.py | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docx/blkcntnr.py b/docx/blkcntnr.py index bdd07cb78..b5e806911 100644 --- a/docx/blkcntnr.py +++ b/docx/blkcntnr.py @@ -52,6 +52,10 @@ def add_table(self, rows, cols, width): self._element._insert_tbl(tbl) return Table(tbl, self) + def end_bookmark(self, bookmark): + """Closes supplied bookmark at the end of this block-item-container.""" + raise NotImplementedError + @property def paragraphs(self): """ diff --git a/docx/document.py b/docx/document.py index 8d41447b4..28db658c3 100644 --- a/docx/document.py +++ b/docx/document.py @@ -114,7 +114,7 @@ def core_properties(self): def end_bookmark(self, bookmark): """Closes supplied bookmark at the end of the document.""" - raise NotImplementedError + return self._body.end_bookmark(bookmark) @property def inline_shapes(self): diff --git a/tests/test_document.py b/tests/test_document.py index b316f4285..f9377be6e 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -131,6 +131,16 @@ def it_provides_access_to_its_settings(self, settings_fixture): document, settings_ = settings_fixture assert document.settings is settings_ + 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_can_start_a_bookmark(self, _body_prop_, body_, bookmark_): _body_prop_.return_value = body_ body_.start_bookmark.return_value = bookmark_ From 552310f5ad783c27a784b6354538ee27f430201d Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Tue, 3 Sep 2019 19:57:19 +0200 Subject: [PATCH 40/52] blkcntnr: add BlockItemContainer.end_bookmark() --- docx/blkcntnr.py | 5 +++- docx/oxml/document.py | 1 + docx/oxml/section.py | 1 + docx/oxml/table.py | 1 + features/doc-document.feature | 2 +- features/hdr-header-footer.feature | 3 +- features/tbl-cell.feature | 2 +- tests/test_blkcntnr.py | 48 ++++++++++++++++++++++++++++++ 8 files changed, 58 insertions(+), 5 deletions(-) diff --git a/docx/blkcntnr.py b/docx/blkcntnr.py index b5e806911..96373f353 100644 --- a/docx/blkcntnr.py +++ b/docx/blkcntnr.py @@ -54,7 +54,10 @@ def add_table(self, rows, cols, width): def end_bookmark(self, bookmark): """Closes supplied bookmark at the end of this block-item-container.""" - raise NotImplementedError + bookmarkend = self._element._add_bookmarkEnd() + bookmarkend.id = bookmark.id + bookmark._bookmarkEnd = bookmarkend + return bookmark @property def paragraphs(self): diff --git a/docx/oxml/document.py b/docx/oxml/document.py index 0d0e4d774..42c1fda19 100644 --- a/docx/oxml/document.py +++ b/docx/oxml/document.py @@ -29,6 +29,7 @@ class CT_Body(BaseOxmlElement): p = ZeroOrMore("w:p", successors=("w:sectPr",)) tbl = ZeroOrMore("w:tbl", successors=("w:sectPr",)) + bookmarkEnd = ZeroOrMore("w:bookmarkEnd", successors=("w:sectPr",)) bookmarkStart = ZeroOrMore("w:bookmarkStart", successors=("w:sectPr",)) sectPr = ZeroOrOne("w:sectPr", successors=()) diff --git a/docx/oxml/section.py b/docx/oxml/section.py index 39dfbb684..e84ef2188 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -22,6 +22,7 @@ class CT_HdrFtr(BaseOxmlElement): p = ZeroOrMore("w:p", successors=()) tbl = ZeroOrMore("w:tbl", successors=()) + bookmarkEnd = ZeroOrMore("w:bookmarkEnd", successors=()) bookmarkStart = ZeroOrMore("w:bookmarkStart", successors=()) def add_bookmarkStart(self, name, bookmark_id): diff --git a/docx/oxml/table.py b/docx/oxml/table.py index e9ced7c3d..f087e7d16 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -398,6 +398,7 @@ class CT_Tc(BaseOxmlElement): tcPr = ZeroOrOne("w:tcPr") # bunches of successors, overriding insert p = OneOrMore("w:p") tbl = OneOrMore("w:tbl") + bookmarkEnd = ZeroOrMore("w:bookmarkEnd", successors=()) bookmarkStart = ZeroOrMore("w:bookmarkStart", successors=()) def add_bookmarkStart(self, name, bookmark_id): diff --git a/features/doc-document.feature b/features/doc-document.feature index 1e925e864..e53107f79 100644 --- a/features/doc-document.feature +++ b/features/doc-document.feature @@ -9,7 +9,7 @@ Feature: Document properties and methods Then bookmark.name == "Target" And bookmark.id is an int - @wip + Scenario: Document.end_bookmark() Given a Document object as document When I assign bookmark = document.start_bookmark("Target") diff --git a/features/hdr-header-footer.feature b/features/hdr-header-footer.feature index a99943866..957d952d2 100644 --- a/features/hdr-header-footer.feature +++ b/features/hdr-header-footer.feature @@ -58,7 +58,6 @@ Feature: Header and footer behaviors | with no | - @wip Scenario Outline: _Header.end_bookmark() Given a _Header object header definition as header When I assign bookmark = header.start_bookmark("Target") @@ -125,7 +124,7 @@ Feature: Header and footer behaviors | with a | | with no | - @wip + Scenario Outline: _Footer.end_bookmark() Given a _Footer object footer definition as footer When I assign bookmark = footer.start_bookmark("Target") diff --git a/features/tbl-cell.feature b/features/tbl-cell.feature index cba50cb63..575e6f1ec 100644 --- a/features/tbl-cell.feature +++ b/features/tbl-cell.feature @@ -3,7 +3,7 @@ Feature: _Cell properties and methods As a developer using python-docx I need properties and methods on a _Cell object - @wip + Scenario: _Cell.end_bookmark() Given a _Cell object as cell When I assign bookmark = cell.start_bookmark("Target") diff --git a/tests/test_blkcntnr.py b/tests/test_blkcntnr.py index a8efeb352..cec7e789a 100644 --- a/tests/test_blkcntnr.py +++ b/tests/test_blkcntnr.py @@ -64,6 +64,18 @@ def it_provides_access_to_the_paragraphs_it_contains(self, paragraphs_fixture): count += 1 assert count == expected_count + def it_can_end_a_bookmark(self, end_bookmark_fixture): + blockContainer, name, bookmarkStart, expected_xml = end_bookmark_fixture + + blkcntnr = BlockItemContainer(blockContainer, None) + bookmark_ = _Bookmark((bookmarkStart, None)) + + bookmark = blkcntnr.end_bookmark(bookmark_) + + assert blkcntnr._element.xml == expected_xml + assert bookmark.name == name + assert bookmark is bookmark_ + def it_can_start_a_bookmark( self, start_bookmark_fixture, @@ -160,6 +172,42 @@ def bookmarks_fixture(self, request): parent_part_ = instance_mock(request, PartCls) return parent_part_ + @pytest.fixture( + params=[ + # ---document body--- + ( + "w:body/w:bookmarkStart{w:name=bmk-1, w:id=0}", + "w:bookmarkStart{w:name=bmk-1, w:id=0}", + "w:body/(w:bookmarkStart{w:name=bmk-1, w:id=0},w:bookmarkEnd{w:id=0})", + ), + # ---table cell--- + ( + "w:tc/(w:p,w:bookmarkStart{w:name=bmk-1, w:id=1})", + "w:bookmarkStart{w:name=bmk-1, w:id=1}", + "w:tc/(w:p,w:bookmarkStart{w:name=bmk-1, w:id=1},w:bookmarkEnd{w:id=1})", + ), + # ---header--- + ( + "w:hdr/(w:bookmarkStart{w:name=bmk-1, w:id=42})", + "w:bookmarkStart{w:name=bmk-1, w:id=42}", + "w:hdr/(w:bookmarkStart{w:name=bmk-1, w:id=42},w:bookmarkEnd{w:id=42})", + ), + # ---footer--- + ( + "w:ftr/(w:bookmarkStart{w:name=bmk-1, w:id=24})", + "w:bookmarkStart{w:name=bmk-1, w:id=24}", + "w:ftr/(w:bookmarkStart{w:name=bmk-1, w:id=24},w:bookmarkEnd{w:id=24})", + ), + ] + ) + def end_bookmark_fixture(self, request): + cxml, bookmark_cxml, expected_cxml = request.param + blockContainer = element(cxml) + bookmarkStart = element(bookmark_cxml) + expected_xml = xml(expected_cxml) + name = "bmk-1" + return blockContainer, name, bookmarkStart, expected_xml + @pytest.fixture( params=[ ("w:body", 0), From 9d8af78843ca3186d092f9b5e7d730d0c3d8697d Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Thu, 5 Sep 2019 20:40:19 +0200 Subject: [PATCH 41/52] acpt: add Paragraph.start_bookmark() and Paragraph.end_bookmark() scenarios --- docx/text/paragraph.py | 27 ++++--- features/par-paragraph.feature | 19 +++++ features/steps/paragraph.py | 129 +++++++++++++++++++-------------- 3 files changed, 108 insertions(+), 67 deletions(-) create mode 100644 features/par-paragraph.feature diff --git a/docx/text/paragraph.py b/docx/text/paragraph.py index 4fb583b94..d09d3ad05 100644 --- a/docx/text/paragraph.py +++ b/docx/text/paragraph.py @@ -4,20 +4,20 @@ Paragraph-related proxy types. """ -from __future__ import ( - absolute_import, division, print_function, unicode_literals -) +from __future__ import absolute_import, division, print_function, unicode_literals -from ..enum.style import WD_STYLE_TYPE -from .parfmt import ParagraphFormat -from .run import Run -from ..shared import Parented +from docx.bookmark import _Bookmark +from docx.enum.style import WD_STYLE_TYPE +from docx.shared import Parented +from docx.text.parfmt import ParagraphFormat +from docx.text.run import Run class Paragraph(Parented): """ Proxy object wrapping ```` element. """ + def __init__(self, p, parent): super(Paragraph, self).__init__(parent) self._p = self._element = p @@ -92,6 +92,13 @@ def runs(self): """ return [Run(r, self) for r in self._p.r_lst] + def start_bookmark(self, name): + """Return newly-added |_Bookmark| object identified by `name`. + + The returned bookmark is anchored at the end of this paragraph. + """ + raise NotImplementedError + @property def style(self): """ @@ -107,9 +114,7 @@ def style(self): @style.setter def style(self, style_or_name): - style_id = self.part.get_style_id( - style_or_name, WD_STYLE_TYPE.PARAGRAPH - ) + style_id = self.part.get_style_id(style_or_name, WD_STYLE_TYPE.PARAGRAPH) self._p.style = style_id @property @@ -126,7 +131,7 @@ def text(self): Paragraph-level formatting, such as style, is preserved. All run-level formatting, such as bold or italic, is removed. """ - text = '' + text = "" for run in self.runs: text += run.text return text diff --git a/features/par-paragraph.feature b/features/par-paragraph.feature new file mode 100644 index 000000000..502ff9f3b --- /dev/null +++ b/features/par-paragraph.feature @@ -0,0 +1,19 @@ +Feature: Paragraph properties and methods + In order to manipulate a paragraph within a Word docment + As a developer using python-docx + I need properties and methods on the Paragraph object + + @wip + Scenario: Paragraph.start_bookmark() + Given a Paragraph object as paragraph + When I assign bookmark = paragraph.start_bookmark("Target") + Then bookmark.name == "Target" + And bookmark.id is an int + + @wip + Scenario: Paragraph.end_bookmark() + Given a Paragraph object as paragraph + When I assign bookmark = paragraph.start_bookmark("Target") + And I end bookmark by calling paragraph.end_bookmark(bookmark) + And I assign bookmark = document.bookmarks.get("Target") + Then bookmark.name == "Target" diff --git a/features/steps/paragraph.py b/features/steps/paragraph.py index 3f47df9f1..8f13e6a51 100644 --- a/features/steps/paragraph.py +++ b/features/steps/paragraph.py @@ -15,83 +15,100 @@ # given =================================================== -@given('a document containing three paragraphs') + +@given("a document containing three paragraphs") def given_a_document_containing_three_paragraphs(context): document = Document() - document.add_paragraph('foo') - document.add_paragraph('bar') - document.add_paragraph('baz') + document.add_paragraph("foo") + document.add_paragraph("bar") + document.add_paragraph("baz") context.document = document -@given('a paragraph having {align_type} alignment') +@given("a paragraph having {align_type} alignment") def given_a_paragraph_align_type_alignment(context, align_type): paragraph_idx = { - 'inherited': 0, - 'left': 1, - 'center': 2, - 'right': 3, - 'justified': 4, + "inherited": 0, + "left": 1, + "center": 2, + "right": 3, + "justified": 4, }[align_type] - document = Document(test_docx('par-alignment')) + document = Document(test_docx("par-alignment")) context.paragraph = document.paragraphs[paragraph_idx] -@given('a paragraph having {style_state} style') +@given("a paragraph having {style_state} style") def given_a_paragraph_having_style(context, style_state): - paragraph_idx = { - 'no specified': 0, - 'a missing': 1, - 'Heading 1': 2, - 'Body Text': 3, - }[style_state] - document = context.document = Document(test_docx('par-known-styles')) + paragraph_idx = {"no specified": 0, "a missing": 1, "Heading 1": 2, "Body Text": 3}[ + style_state + ] + document = context.document = Document(test_docx("par-known-styles")) context.paragraph = document.paragraphs[paragraph_idx] -@given('a paragraph with content and formatting') +@given("a Paragraph object as paragraph") +def given_a_paragraph_object_as_paragraph(context): + document = Document() + paragraph = document.add_paragraph() + context.paragraph = paragraph + context.document = document + + +@given("a paragraph with content and formatting") def given_a_paragraph_with_content_and_formatting(context): - document = Document(test_docx('par-known-paragraphs')) + document = Document(test_docx("par-known-paragraphs")) context.paragraph = document.paragraphs[0] # when ==================================================== -@when('I add a run to the paragraph') + +@when("I add a run to the paragraph") def when_add_new_run_to_paragraph(context): context.run = context.p.add_run() -@when('I assign a {style_type} to paragraph.style') +@when("I assign a {style_type} to paragraph.style") def when_I_assign_a_style_type_to_paragraph_style(context, style_type): paragraph = context.paragraph - style = context.style = context.document.styles['Heading 1'] - style_spec = { - 'style object': style, - 'style name': 'Heading 1', - }[style_type] + style = context.style = context.document.styles["Heading 1"] + style_spec = {"style object": style, "style name": "Heading 1"}[style_type] paragraph.style = style_spec -@when('I clear the paragraph content') +@when('I assign bookmark = paragraph.start_bookmark("Target")') +def when_I_assign_bookmark_eq_paragraph_start_bookmark(context): + paragraph = context.paragraph + context.bookmark = paragraph.start_bookmark("Target") + + +@when("I clear the paragraph content") def when_I_clear_the_paragraph_content(context): context.paragraph.clear() -@when('I insert a paragraph above the second paragraph') +@when("I end bookmark by calling paragraph.end_bookmark(bookmark)") +def when_I_end_bookmark_by_calling_paragraph_end_bookmark_with_bookmark(context): + paragraph = context.paragraph + paragraph.end_bookmark(context.bookmark) + + +@when("I insert a paragraph above the second paragraph") def when_I_insert_a_paragraph_above_the_second_paragraph(context): paragraph = context.document.paragraphs[1] - paragraph.insert_paragraph_before('foobar', 'Heading1') + paragraph.insert_paragraph_before("foobar", "Heading1") -@when('I set the paragraph text') +@when("I set the paragraph text") def when_I_set_the_paragraph_text(context): - context.paragraph.text = 'bar\tfoo\r' + context.paragraph.text = "bar\tfoo\r" # then ===================================================== -@then('paragraph.paragraph_format is its ParagraphFormat object') + +@then("paragraph.paragraph_format is its ParagraphFormat object") def then_paragraph_paragraph_format_is_its_parfmt_object(context): paragraph = context.paragraph paragraph_format = paragraph.paragraph_format @@ -99,24 +116,24 @@ def then_paragraph_paragraph_format_is_its_parfmt_object(context): assert paragraph_format.element is paragraph._element -@then('paragraph.style is {value_key}') +@then("paragraph.style is {value_key}") def then_paragraph_style_is_value(context, value_key): styles = context.document.styles expected_value = { - 'Normal': styles['Normal'], - 'Heading 1': styles['Heading 1'], - 'Body Text': styles['Body Text'], + "Normal": styles["Normal"], + "Heading 1": styles["Heading 1"], + "Body Text": styles["Body Text"], }[value_key] paragraph = context.paragraph assert paragraph.style == expected_value -@then('the document contains four paragraphs') +@then("the document contains four paragraphs") def then_the_document_contains_four_paragraphs(context): assert len(context.document.paragraphs) == 4 -@then('the document contains the text I added') +@then("the document contains the text I added") def then_document_contains_text_I_added(context): document = Document(saved_docx_path) paragraphs = document.paragraphs @@ -125,46 +142,46 @@ def then_document_contains_text_I_added(context): assert r.text == test_text -@then('the paragraph alignment property value is {align_value}') +@then("the paragraph alignment property value is {align_value}") def then_the_paragraph_alignment_prop_value_is_value(context, align_value): expected_value = { - 'None': None, - 'WD_ALIGN_PARAGRAPH.LEFT': WD_ALIGN_PARAGRAPH.LEFT, - 'WD_ALIGN_PARAGRAPH.CENTER': WD_ALIGN_PARAGRAPH.CENTER, - 'WD_ALIGN_PARAGRAPH.RIGHT': WD_ALIGN_PARAGRAPH.RIGHT, + "None": None, + "WD_ALIGN_PARAGRAPH.LEFT": WD_ALIGN_PARAGRAPH.LEFT, + "WD_ALIGN_PARAGRAPH.CENTER": WD_ALIGN_PARAGRAPH.CENTER, + "WD_ALIGN_PARAGRAPH.RIGHT": WD_ALIGN_PARAGRAPH.RIGHT, }[align_value] assert context.paragraph.alignment == expected_value -@then('the paragraph formatting is preserved') +@then("the paragraph formatting is preserved") def then_the_paragraph_formatting_is_preserved(context): paragraph = context.paragraph - assert paragraph.style.name == 'Heading 1' + assert paragraph.style.name == "Heading 1" -@then('the paragraph has no content') +@then("the paragraph has no content") def then_the_paragraph_has_no_content(context): - assert context.paragraph.text == '' + assert context.paragraph.text == "" -@then('the paragraph has the style I set') +@then("the paragraph has the style I set") def then_the_paragraph_has_the_style_I_set(context): paragraph, expected_style = context.paragraph, context.style assert paragraph.style == expected_style -@then('the paragraph has the text I set') +@then("the paragraph has the text I set") def then_the_paragraph_has_the_text_I_set(context): - assert context.paragraph.text == 'bar\tfoo\n' + assert context.paragraph.text == "bar\tfoo\n" -@then('the style of the second paragraph matches the style I set') +@then("the style of the second paragraph matches the style I set") def then_the_style_of_the_second_paragraph_matches_the_style_I_set(context): second_paragraph = context.document.paragraphs[1] - assert second_paragraph.style.name == 'Heading 1' + assert second_paragraph.style.name == "Heading 1" -@then('the text of the second paragraph matches the text I set') +@then("the text of the second paragraph matches the text I set") def then_the_text_of_the_second_paragraph_matches_the_text_I_set(context): second_paragraph = context.document.paragraphs[1] - assert second_paragraph.text == 'foobar' + assert second_paragraph.text == "foobar" From c0f0c1ad7869a439209e370d5d2ba4626f9bf4a5 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Thu, 5 Sep 2019 20:50:16 +0200 Subject: [PATCH 42/52] par: add Paragraph.start_bookmark() --- docx/oxml/text/paragraph.py | 26 +++- docx/text/paragraph.py | 14 ++- tests/text/test_paragraph.py | 232 ++++++++++++++++++++++------------- 3 files changed, 181 insertions(+), 91 deletions(-) diff --git a/docx/oxml/text/paragraph.py b/docx/oxml/text/paragraph.py index 5e4213776..c86bc1795 100644 --- a/docx/oxml/text/paragraph.py +++ b/docx/oxml/text/paragraph.py @@ -4,16 +4,30 @@ Custom element classes related to paragraphs (CT_P). """ -from ..ns import qn -from ..xmlchemy import BaseOxmlElement, OxmlElement, ZeroOrMore, ZeroOrOne +from docx.oxml.ns import qn +from docx.oxml.xmlchemy import BaseOxmlElement, OxmlElement, ZeroOrMore, ZeroOrOne class CT_P(BaseOxmlElement): """ ```` element, containing the properties and text for a paragraph. """ - pPr = ZeroOrOne('w:pPr') - r = ZeroOrMore('w:r') + + bookmarkStart = ZeroOrMore("w:bookmarkStart") + pPr = ZeroOrOne("w:pPr") + r = ZeroOrMore("w:r") + + 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 def _insert_pPr(self, pPr): self.insert(0, pPr) @@ -23,7 +37,7 @@ def add_p_before(self): """ Return a new ```` element inserted directly prior to this one. """ - new_p = OxmlElement('w:p') + new_p = OxmlElement("w:p") self.addprevious(new_p) return new_p @@ -48,7 +62,7 @@ def clear_content(self): Remove all child elements, except the ```` element if present. """ for child in self[:]: - if child.tag == qn('w:pPr'): + if child.tag == qn("w:pPr"): continue self.remove(child) diff --git a/docx/text/paragraph.py b/docx/text/paragraph.py index d09d3ad05..878233064 100644 --- a/docx/text/paragraph.py +++ b/docx/text/paragraph.py @@ -8,7 +8,7 @@ from docx.bookmark import _Bookmark from docx.enum.style import WD_STYLE_TYPE -from docx.shared import Parented +from docx.shared import lazyproperty, Parented from docx.text.parfmt import ParagraphFormat from docx.text.run import Run @@ -54,6 +54,11 @@ def alignment(self): def alignment(self, value): self._p.alignment = value + @lazyproperty + def _bookmarks(self): + """Global |Bookmarks| object for overall document.""" + raise NotImplementedError + def clear(self): """ Return this same paragraph after removing all its content. @@ -97,7 +102,12 @@ def start_bookmark(self, name): The returned bookmark is anchored at the end of this paragraph. """ - raise NotImplementedError + 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 style(self): diff --git a/tests/text/test_paragraph.py b/tests/text/test_paragraph.py index b8313e61e..6fe0e2817 100644 --- a/tests/text/test_paragraph.py +++ b/tests/text/test_paragraph.py @@ -4,6 +4,8 @@ from __future__ import absolute_import, division, print_function, unicode_literals +import pytest + from docx.enum.style import WD_STYLE_TYPE from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml.text.paragraph import CT_P @@ -12,16 +14,42 @@ from docx.text.paragraph import Paragraph from docx.text.parfmt import ParagraphFormat from docx.text.run import Run - -import pytest +from docx.bookmark import _Bookmark, Bookmarks +from docx.blkcntnr import BlockItemContainer from ..unitutil.cxml import element, xml from ..unitutil.mock import ( - call, class_mock, instance_mock, method_mock, property_mock + ANY, + call, + class_mock, + instance_mock, + method_mock, + property_mock, ) class DescribeParagraph(object): + def it_can_start_a_bookmark( + self, + start_bookmark_fixture, + _bookmarks_prop_, + bookmarks_, + _Bookmark_, + bookmark_, + ): + paragraph_, 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_ + paragraph = Paragraph(paragraph_, None) + + bookmark = paragraph.start_bookmark(name) + + _Bookmark_.assert_called_once_with((ANY, None)) + assert paragraph._element.xml == expected_xml + assert bookmark is bookmark_ def it_knows_its_paragraph_style(self, style_get_fixture): paragraph, style_id_, style_ = style_get_fixture @@ -68,9 +96,7 @@ def it_provides_access_to_its_paragraph_format(self, parfmt_fixture): def it_provides_access_to_the_runs_it_contains(self, runs_fixture): paragraph, Run_, r_, r_2_, run_, run_2_ = runs_fixture runs = paragraph.runs - assert Run_.mock_calls == [ - call(r_, paragraph), call(r_2_, paragraph) - ] + assert Run_.mock_calls == [call(r_, paragraph), call(r_2_, paragraph)] assert runs == [run_, run_2_] def it_can_add_a_run_to_itself(self, add_run_fixture): @@ -93,8 +119,7 @@ def it_can_insert_a_paragraph_before_itself(self, insert_before_fixture): assert new_paragraph.style == style assert new_paragraph is paragraph_ - def it_can_remove_its_content_while_preserving_formatting( - self, clear_fixture): + def it_can_remove_its_content_while_preserving_formatting(self, clear_fixture): paragraph, expected_xml = clear_fixture _paragraph = paragraph.clear() assert paragraph._p.xml == expected_xml @@ -108,60 +133,64 @@ def it_inserts_a_paragraph_before_to_help(self, _insert_before_fixture): # fixtures ------------------------------------------------------- - @pytest.fixture(params=[ - ('w:p', None, None, 'w:p/w:r'), - ('w:p', 'foobar', None, 'w:p/w:r/w:t"foobar"'), - ('w:p', None, 'Strong', 'w:p/w:r'), - ('w:p', 'foobar', 'Strong', 'w:p/w:r/w:t"foobar"'), - ]) + @pytest.fixture( + params=[ + ("w:p", None, None, "w:p/w:r"), + ("w:p", "foobar", None, 'w:p/w:r/w:t"foobar"'), + ("w:p", None, "Strong", "w:p/w:r"), + ("w:p", "foobar", "Strong", 'w:p/w:r/w:t"foobar"'), + ] + ) def add_run_fixture(self, request, run_style_prop_): before_cxml, text, style, after_cxml = request.param paragraph = Paragraph(element(before_cxml), None) expected_xml = xml(after_cxml) return paragraph, text, style, run_style_prop_, expected_xml - @pytest.fixture(params=[ - ('w:p/w:pPr/w:jc{w:val=center}', WD_ALIGN_PARAGRAPH.CENTER), - ('w:p', None), - ]) + @pytest.fixture( + params=[ + ("w:p/w:pPr/w:jc{w:val=center}", WD_ALIGN_PARAGRAPH.CENTER), + ("w:p", None), + ] + ) def alignment_get_fixture(self, request): cxml, expected_alignment_value = request.param paragraph = Paragraph(element(cxml), None) return paragraph, expected_alignment_value - @pytest.fixture(params=[ - ('w:p', WD_ALIGN_PARAGRAPH.LEFT, - 'w:p/w:pPr/w:jc{w:val=left}'), - ('w:p/w:pPr/w:jc{w:val=left}', WD_ALIGN_PARAGRAPH.CENTER, - 'w:p/w:pPr/w:jc{w:val=center}'), - ('w:p/w:pPr/w:jc{w:val=left}', None, - 'w:p/w:pPr'), - ('w:p', None, 'w:p/w:pPr'), - ]) + @pytest.fixture( + params=[ + ("w:p", WD_ALIGN_PARAGRAPH.LEFT, "w:p/w:pPr/w:jc{w:val=left}"), + ( + "w:p/w:pPr/w:jc{w:val=left}", + WD_ALIGN_PARAGRAPH.CENTER, + "w:p/w:pPr/w:jc{w:val=center}", + ), + ("w:p/w:pPr/w:jc{w:val=left}", None, "w:p/w:pPr"), + ("w:p", None, "w:p/w:pPr"), + ] + ) def alignment_set_fixture(self, request): initial_cxml, new_alignment_value, expected_cxml = request.param paragraph = Paragraph(element(initial_cxml), None) expected_xml = xml(expected_cxml) return paragraph, new_alignment_value, expected_xml - @pytest.fixture(params=[ - ('w:p', 'w:p'), - ('w:p/w:pPr', 'w:p/w:pPr'), - ('w:p/w:r/w:t"foobar"', 'w:p'), - ('w:p/(w:pPr, w:r/w:t"foobar")', 'w:p/w:pPr'), - ]) + @pytest.fixture( + params=[ + ("w:p", "w:p"), + ("w:p/w:pPr", "w:p/w:pPr"), + ('w:p/w:r/w:t"foobar"', "w:p"), + ('w:p/(w:pPr, w:r/w:t"foobar")', "w:p/w:pPr"), + ] + ) def clear_fixture(self, request): initial_cxml, expected_cxml = request.param paragraph = Paragraph(element(initial_cxml), None) expected_xml = xml(expected_cxml) return paragraph, expected_xml - @pytest.fixture(params=[ - (None, None), - ('Foo', None), - (None, 'Bar'), - ('Foo', 'Bar'), - ]) + @pytest.fixture(params=[(None, None), ("Foo", None), (None, "Bar"), ("Foo", "Bar")]) def insert_before_fixture(self, request, _insert_paragraph_before_, add_run_): text, style = request.param paragraph_ = _insert_paragraph_before_.return_value @@ -169,9 +198,7 @@ def insert_before_fixture(self, request, _insert_paragraph_before_, add_run_): paragraph_.style = None return text, style, paragraph_, add_run_calls - @pytest.fixture(params=[ - ('w:body/w:p{id=42}', 'w:body/(w:p,w:p{id=42})') - ]) + @pytest.fixture(params=[("w:body/w:p{id=42}", "w:body/(w:p,w:p{id=42})")]) def _insert_before_fixture(self, request): body_cxml, expected_cxml = request.param body = element(body_cxml) @@ -181,7 +208,7 @@ def _insert_before_fixture(self, request): @pytest.fixture def parfmt_fixture(self, ParagraphFormat_, paragraph_format_): - paragraph = Paragraph(element('w:p'), None) + paragraph = Paragraph(element("w:p"), None) return paragraph, ParagraphFormat_, paragraph_format_ @pytest.fixture @@ -190,26 +217,48 @@ def runs_fixture(self, p_, Run_, r_, r_2_, runs_): run_, run_2_ = runs_ return paragraph, Run_, r_, r_2_, run_, run_2_ + @pytest.fixture( + params=[ + # ---bookmarkStart as first element--- + ("w:p", 0, "w:p/w:bookmarkStart{w:name=bmk-1, w:id=0}"), + # ---bookmarkStart after a run--- + ("w:p/(w:r)", 1, "w:p/(w:r,w:bookmarkStart{w:name=bmk-1, w:id=1})"), + ] + ) + def start_bookmark_fixture(self, request): + cxml, next_id, expected_cxml = request.param + paragraph = element(cxml) + expected_xml = xml(expected_cxml) + name = "bmk-1" + return paragraph, name, next_id, expected_xml + @pytest.fixture def style_get_fixture(self, part_prop_): - style_id = 'Foobar' - p_cxml = 'w:p/w:pPr/w:pStyle{w:val=%s}' % style_id + style_id = "Foobar" + p_cxml = "w:p/w:pPr/w:pStyle{w:val=%s}" % style_id paragraph = Paragraph(element(p_cxml), None) style_ = part_prop_.return_value.get_style.return_value return paragraph, style_id, style_ - @pytest.fixture(params=[ - ('w:p', 'Heading 1', 'Heading1', - 'w:p/w:pPr/w:pStyle{w:val=Heading1}'), - ('w:p/w:pPr', 'Heading 1', 'Heading1', - 'w:p/w:pPr/w:pStyle{w:val=Heading1}'), - ('w:p/w:pPr/w:pStyle{w:val=Heading1}', 'Heading 2', 'Heading2', - 'w:p/w:pPr/w:pStyle{w:val=Heading2}'), - ('w:p/w:pPr/w:pStyle{w:val=Heading1}', 'Normal', None, - 'w:p/w:pPr'), - ('w:p', None, None, - 'w:p/w:pPr'), - ]) + @pytest.fixture( + params=[ + ("w:p", "Heading 1", "Heading1", "w:p/w:pPr/w:pStyle{w:val=Heading1}"), + ( + "w:p/w:pPr", + "Heading 1", + "Heading1", + "w:p/w:pPr/w:pStyle{w:val=Heading1}", + ), + ( + "w:p/w:pPr/w:pStyle{w:val=Heading1}", + "Heading 2", + "Heading2", + "w:p/w:pPr/w:pStyle{w:val=Heading2}", + ), + ("w:p/w:pPr/w:pStyle{w:val=Heading1}", "Normal", None, "w:p/w:pPr"), + ("w:p", None, None, "w:p/w:pPr"), + ] + ) def style_set_fixture(self, request, part_prop_): p_cxml, value, style_id, expected_cxml = request.param paragraph = Paragraph(element(p_cxml), None) @@ -217,17 +266,19 @@ def style_set_fixture(self, request, part_prop_): expected_xml = xml(expected_cxml) return paragraph, value, expected_xml - @pytest.fixture(params=[ - ('w:p', ''), - ('w:p/w:r', ''), - ('w:p/w:r/w:t', ''), - ('w:p/w:r/w:t"foo"', 'foo'), - ('w:p/w:r/(w:t"foo", w:t"bar")', 'foobar'), - ('w:p/w:r/(w:t"fo ", w:t"bar")', 'fo bar'), - ('w:p/w:r/(w:t"foo", w:tab, w:t"bar")', 'foo\tbar'), - ('w:p/w:r/(w:t"foo", w:br, w:t"bar")', 'foo\nbar'), - ('w:p/w:r/(w:t"foo", w:cr, w:t"bar")', 'foo\nbar'), - ]) + @pytest.fixture( + params=[ + ("w:p", ""), + ("w:p/w:r", ""), + ("w:p/w:r/w:t", ""), + ('w:p/w:r/w:t"foo"', "foo"), + ('w:p/w:r/(w:t"foo", w:t"bar")', "foobar"), + ('w:p/w:r/(w:t"fo ", w:t"bar")', "fo bar"), + ('w:p/w:r/(w:t"foo", w:tab, w:t"bar")', "foo\tbar"), + ('w:p/w:r/(w:t"foo", w:br, w:t"bar")', "foo\nbar"), + ('w:p/w:r/(w:t"foo", w:cr, w:t"bar")', "foo\nbar"), + ] + ) def text_get_fixture(self, request): p_cxml, expected_text_value = request.param paragraph = Paragraph(element(p_cxml), None) @@ -235,17 +286,33 @@ def text_get_fixture(self, request): @pytest.fixture def text_set_fixture(self): - paragraph = Paragraph(element('w:p'), None) - paragraph.add_run('must not appear in result') - new_text_value = 'foo\tbar\rbaz\n' - expected_text_value = 'foo\tbar\nbaz\n' + paragraph = Paragraph(element("w:p"), None) + paragraph.add_run("must not appear in result") + new_text_value = "foo\tbar\rbaz\n" + expected_text_value = "foo\tbar\nbaz\n" return paragraph, new_text_value, expected_text_value # fixture components --------------------------------------------- @pytest.fixture def add_run_(self, request): - return method_mock(request, Paragraph, 'add_run') + return method_mock(request, Paragraph, "add_run") + + @pytest.fixture + def _Bookmark_(self, request): + return class_mock(request, "docx.text.paragraph._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, Paragraph, "_bookmarks") @pytest.fixture def document_part_(self, request): @@ -253,7 +320,7 @@ def document_part_(self, request): @pytest.fixture def _insert_paragraph_before_(self, request): - return method_mock(request, Paragraph, '_insert_paragraph_before') + return method_mock(request, Paragraph, "_insert_paragraph_before") @pytest.fixture def p_(self, request, r_, r_2_): @@ -262,8 +329,9 @@ def p_(self, request, r_, r_2_): @pytest.fixture def ParagraphFormat_(self, request, paragraph_format_): return class_mock( - request, 'docx.text.paragraph.ParagraphFormat', - return_value=paragraph_format_ + request, + "docx.text.paragraph.ParagraphFormat", + return_value=paragraph_format_, ) @pytest.fixture @@ -272,15 +340,13 @@ def paragraph_format_(self, request): @pytest.fixture def part_prop_(self, request, document_part_): - return property_mock( - request, Paragraph, 'part', return_value=document_part_ - ) + return property_mock(request, Paragraph, "part", return_value=document_part_) @pytest.fixture def Run_(self, request, runs_): run_, run_2_ = runs_ return class_mock( - request, 'docx.text.paragraph.Run', side_effect=[run_, run_2_] + request, "docx.text.paragraph.Run", side_effect=[run_, run_2_] ) @pytest.fixture @@ -293,10 +359,10 @@ def r_2_(self, request): @pytest.fixture def run_style_prop_(self, request): - return property_mock(request, Run, 'style') + return property_mock(request, Run, "style") @pytest.fixture def runs_(self, request): - run_ = instance_mock(request, Run, name='run_') - run_2_ = instance_mock(request, Run, name='run_2_') + run_ = instance_mock(request, Run, name="run_") + run_2_ = instance_mock(request, Run, name="run_2_") return run_, run_2_ From 18f59c3d24d9c240fa1205234b91e4fede829c5f Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Sat, 7 Sep 2019 06:46:11 +0200 Subject: [PATCH 43/52] par: add Paragraph._bookmarks --- docx/text/paragraph.py | 6 +++++- features/par-paragraph.feature | 2 +- tests/text/test_paragraph.py | 20 +++++++++++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docx/text/paragraph.py b/docx/text/paragraph.py index 878233064..4617a56a2 100644 --- a/docx/text/paragraph.py +++ b/docx/text/paragraph.py @@ -57,7 +57,7 @@ def alignment(self, value): @lazyproperty def _bookmarks(self): """Global |Bookmarks| object for overall document.""" - raise NotImplementedError + return self.part.bookmarks def clear(self): """ @@ -67,6 +67,10 @@ def clear(self): self._p.clear_content() return self + def end_bookmark(self, bookmark): + """Closes supplied bookmark at the end of this paragraph.""" + raise NotImplementedError + def insert_paragraph_before(self, text=None, style=None): """ Return a newly created paragraph, inserted directly before this diff --git a/features/par-paragraph.feature b/features/par-paragraph.feature index 502ff9f3b..18cfb7777 100644 --- a/features/par-paragraph.feature +++ b/features/par-paragraph.feature @@ -3,7 +3,7 @@ Feature: Paragraph properties and methods As a developer using python-docx I need properties and methods on the Paragraph object - @wip + Scenario: Paragraph.start_bookmark() Given a Paragraph object as paragraph When I assign bookmark = paragraph.start_bookmark("Target") diff --git a/tests/text/test_paragraph.py b/tests/text/test_paragraph.py index 6fe0e2817..2efafac71 100644 --- a/tests/text/test_paragraph.py +++ b/tests/text/test_paragraph.py @@ -11,11 +11,11 @@ from docx.oxml.text.paragraph import CT_P from docx.oxml.text.run import CT_R from docx.parts.document import DocumentPart +from docx.parts.hdrftr import FooterPart, HeaderPart from docx.text.paragraph import Paragraph from docx.text.parfmt import ParagraphFormat from docx.text.run import Run from docx.bookmark import _Bookmark, Bookmarks -from docx.blkcntnr import BlockItemContainer from ..unitutil.cxml import element, xml from ..unitutil.mock import ( @@ -93,6 +93,18 @@ def it_provides_access_to_its_paragraph_format(self, parfmt_fixture): ParagraphFormat_.assert_called_once_with(paragraph._element) assert paragraph_format is paragraph_format_ + 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 = Paragraph(None, None) + + bookmarks = blkcntnr._bookmarks + + assert bookmarks is bookmarks_ + def it_provides_access_to_the_runs_it_contains(self, runs_fixture): paragraph, Run_, r_, r_2_, run_, run_2_ = runs_fixture runs = paragraph.runs @@ -176,6 +188,12 @@ def alignment_set_fixture(self, request): expected_xml = xml(expected_cxml) return paragraph, new_alignment_value, expected_xml + @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=[ ("w:p", "w:p"), From 89b69154a13cbb5e2ee255f868c147c7e3d2afab Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Thu, 5 Sep 2019 20:57:56 +0200 Subject: [PATCH 44/52] par: add Paragraph.end_bookmark() --- docx/oxml/text/paragraph.py | 1 + docx/text/paragraph.py | 5 ++++- features/par-paragraph.feature | 2 +- tests/text/test_paragraph.py | 36 ++++++++++++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/docx/oxml/text/paragraph.py b/docx/oxml/text/paragraph.py index c86bc1795..51981585f 100644 --- a/docx/oxml/text/paragraph.py +++ b/docx/oxml/text/paragraph.py @@ -13,6 +13,7 @@ class CT_P(BaseOxmlElement): ```` element, containing the properties and text for a paragraph. """ + bookmarkEnd = ZeroOrMore("w:bookmarkEnd") bookmarkStart = ZeroOrMore("w:bookmarkStart") pPr = ZeroOrOne("w:pPr") r = ZeroOrMore("w:r") diff --git a/docx/text/paragraph.py b/docx/text/paragraph.py index 4617a56a2..0aec19782 100644 --- a/docx/text/paragraph.py +++ b/docx/text/paragraph.py @@ -69,7 +69,10 @@ def clear(self): def end_bookmark(self, bookmark): """Closes supplied bookmark at the end of this paragraph.""" - raise NotImplementedError + bookmarkend = self._element._add_bookmarkEnd() + bookmarkend.id = bookmark.id + bookmark._bookmarkEnd = bookmarkend + return bookmark def insert_paragraph_before(self, text=None, style=None): """ diff --git a/features/par-paragraph.feature b/features/par-paragraph.feature index 18cfb7777..5331c8513 100644 --- a/features/par-paragraph.feature +++ b/features/par-paragraph.feature @@ -10,7 +10,7 @@ Feature: Paragraph properties and methods Then bookmark.name == "Target" And bookmark.id is an int - @wip + Scenario: Paragraph.end_bookmark() Given a Paragraph object as paragraph When I assign bookmark = paragraph.start_bookmark("Target") diff --git a/tests/text/test_paragraph.py b/tests/text/test_paragraph.py index 2efafac71..3e0ab8b96 100644 --- a/tests/text/test_paragraph.py +++ b/tests/text/test_paragraph.py @@ -29,6 +29,18 @@ class DescribeParagraph(object): + def it_can_end_a_bookmark(self, end_bookmark_fixture): + blockContainer, name, bookmarkStart, expected_xml = end_bookmark_fixture + + blkcntnr = Paragraph(blockContainer, None) + bookmark_ = _Bookmark((bookmarkStart, None)) + + bookmark = blkcntnr.end_bookmark(bookmark_) + + assert blkcntnr._element.xml == expected_xml + assert bookmark.name == name + assert bookmark is bookmark_ + def it_can_start_a_bookmark( self, start_bookmark_fixture, @@ -208,6 +220,30 @@ def clear_fixture(self, request): expected_xml = xml(expected_cxml) return paragraph, expected_xml + @pytest.fixture( + params=[ + # ---at start of paragraph--- + ( + "w:p/(w:bookmarkStart{w:name=bmk-1, w:id=24})", + "w:bookmarkStart{w:name=bmk-1, w:id=24}", + "w:p/(w:bookmarkStart{w:name=bmk-1, w:id=24},w:bookmarkEnd{w:id=24})", + ), + ( + # ---run in between bookmarks--- + 'w:p/(w:bookmarkStart{w:name=bmk-1, w:id=24}, w:r/w:t"foobar")', + "w:bookmarkStart{w:name=bmk-1, w:id=24}", + 'w:p/(w:bookmarkStart{w:name=bmk-1, w:id=24},w:r/w:t"foobar", w:bookmarkEnd{w:id=24})', + ), + ] + ) + def end_bookmark_fixture(self, request): + cxml, bookmark_cxml, expected_cxml = request.param + blockContainer = element(cxml) + bookmarkStart = element(bookmark_cxml) + expected_xml = xml(expected_cxml) + name = "bmk-1" + return blockContainer, name, bookmarkStart, expected_xml + @pytest.fixture(params=[(None, None), ("Foo", None), (None, "Bar"), ("Foo", "Bar")]) def insert_before_fixture(self, request, _insert_paragraph_before_, add_run_): text, style = request.param From a04ef912750731ddfde1e7f7004f2c7550d0b3b4 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Wed, 4 Sep 2019 22:31:48 +0200 Subject: [PATCH 45/52] docs: Updated feature proposal --- docs/dev/analysis/features/bookmarks.rst | 230 ++++++++++++++++------- 1 file changed, 163 insertions(+), 67 deletions(-) diff --git a/docs/dev/analysis/features/bookmarks.rst b/docs/dev/analysis/features/bookmarks.rst index 46d433608..6a4723f84 100644 --- a/docs/dev/analysis/features/bookmarks.rst +++ b/docs/dev/analysis/features/bookmarks.rst @@ -50,11 +50,19 @@ Adding a bookmark:: >>> len(bookmarks) 1 >>> bookmarks.get('Target') - docx.text.bookmark.Bookmark object at 0x00fa1afe1> - >>> bookmarks.get(id=1) - docx.text.bookmark.Bookmark object at 0x00fa1afe1> + + >>> bookmarks.get_by_id(1) + >>> bookmarks[0] - docx.text.bookmark.Bookmark object at 0x00fa1afe1> + + + # A bookmark can be deleted: + >>> len(bookmarks) + >>> 2 + >>> bookmark = bookmarks[0] + >>> bookmark.delete() + >>> len(bookmarks) + >>> 1 Word Behavior @@ -82,19 +90,25 @@ Word Behavior * A bookmark can be *hidden*, which occurs for example when cross-references are inserted into the document. -* ? Do bookmarks need to be unique across all stories? (like headers, footers, - etc.)? This could be trouble for us because we don't yet have access to - those "stories". +* 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.) -* ? How do overlapping bookmarks behave? Are those permitted? Like new one - starts before prior one finishes? +* Bookmarks may overlap i.e. A new bookmark is started as the previous + one is not yet ended. - ? What about "nested" bookmarks? Are those permitted? Line second bookmark - starts and ends after first one starts and before it ends? +* 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 ------------- @@ -136,81 +150,163 @@ 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 +Bookmarks object: + +https://msdn.microsoft.com/en-us/vba/word-vba/articles/bookmarks-object-word -* Bookmarks.Add(name, range) -* Bookmarks.Exists(name) -* Bookmarks.Item(index) -* Bookmarks.DefaultSorting -* Bookmarks.ShowHidden +Methods: +* Bookmarks.Exists(name) - Checks if bookmark name exists in document. +* Bookmarks.Item(index) - Returns bookmark based on id or name. -.. _Bookmark objects: - https://msdn.microsoft.com/en-us/vba/word-vba/articles/bookmark-object-word +Properties: +* Bookmarks.Count - Number of bookmarks -* Bookmark.Delete() -* Bookmark.Column (boolean) -* Bookmark.Empty (boolean, True if contains no text.) -* Bookmark.End -* Bookmark.Name -* Bookmark.Start -* Bookmark.StoryType +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 -------------- :: - - - + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + - + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + + + - + + + + + + + + + + + + + + + + + + + + + + From 27377d9835cafa0e86ed7ae0d1746f9590d76a17 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Mon, 6 Aug 2018 17:20:52 +0200 Subject: [PATCH 46/52] docs: document feature analysis fields --- docs/dev/analysis/features/text/fields.rst | 495 +++++++++++++++++++++ 1 file changed, 495 insertions(+) create mode 100644 docs/dev/analysis/features/text/fields.rst diff --git a/docs/dev/analysis/features/text/fields.rst b/docs/dev/analysis/features/text/fields.rst new file mode 100644 index 000000000..ede761d9f --- /dev/null +++ b/docs/dev/analysis/features/text/fields.rst @@ -0,0 +1,495 @@ +.. _fields: + +Fields +====== + +Fields in Word are used as placeholders for data that might change in a +document and for creating form letters and labels in mail merge documents. + +Word inserts fields automatically when you use particular commands, such as +when you insert a page number, when you insert a document building block such +as a cover page, or when you create a table of contents. You can also manually +insert fields to automate aspects of your document, such as merging data from +a data source or performing calculations. + +An overview of possible field codes is provided here + +https://support.office.com/en-us/article/list-of-field-codes-in-word-1ad6d91a-55a7-4a8d-b535-cf7888659a51#__toc316563575 + + +FieldChar class +--------------- + +fldChar (Complex Field Character) + +This element specifies the presence of a complex field character at the +current location in the parent run. A complex field character is a special +character which delimits the start and end of a complex field or separates its +field codes from its current field result. + +A complex field is defined via the use of the two required complex field +characters: a start character, which specifies the beginning of a complex +field within the document content; and an end character, which specifies the +end of a complex field. This syntax allows multiple fields to be embedded +(or "nested") within each other in a document. + +As well, because a complex field can specify both its field codes and its +current result within the document, these two items are separated by the +optional separator character, which defines the end of the field codes and the +beginning of the field contents. The omission of this character shall be used +to specify that the contents of the field are entirely field codes +(i.e. the field has no result). + +See --> https://msdn.microsoft.com/en-us/library/office/documentformat.openxml.wordprocessing.fieldchar.aspx + +SimpleField class +----------------- + +fldSimple (Simple Field) + +This element specifies the presence of a simple field at the current location +in the document. The semantics of this field are defined via its field codes. + +See --> https://msdn.microsoft.com/en-us/library/office/documentformat.openxml.wordprocessing.simplefield.aspx + + +Protocol +-------- + +Since field codes are located in a run, the obvious location would be in the +Run object. + +>>> Run.add_field({ FIELD NAME Instructions Optional switches }) + +FIELD NAME This is the name that appears in the list of field names + in the Field dialog box. + +Instructions These are any instructions or variables that are used in a + particular field. Not all fields have parameters, and in some + fields, parameters are optional instead of required. + +Optional switches These are any optional settings that are available for a + particular field. Not all fields have switches available, + other than those that govern the formatting of the field + results. + +Word UI features and behaviors +------------------------------ + +* The field codes are accessed via the `Quick Parts` - field dialog. This + provides access to all the different field codes available within the word + editor. + +* The field codes are evaluated when the field codes are updated within the + text editor (Ctrl+F9 or Print Preview) + +Related items from Microsoft VBA API +------------------------------------ + +* `Fields` + https://docs.microsoft.com/en-us/dotnet/api/microsoft.office.interop.word.fields?view=word-pia + + +* `Field` + https://docs.microsoft.com/en-us/dotnet/api/microsoft.office.interop.word.field?view=word-pia + +* Apparently has a property `kind` which has the enumeration `WdFieldKind` + +Enumerations +------------ + +--->> hele linker kolom??? https://docs.microsoft.com/en-us/dotnet/api/microsoft.office.interop.word.wdmergetarget?view=word-pia + + +WdFieldType +~~~~~~~~~~~ + +https://docs.microsoft.com/en-us/dotnet/api/microsoft.office.interop.word.wdfieldtype?view=word-pia + +https://msdn.microsoft.com/en-us/vba/word-vba/articles/wdfieldtype-enumeration-word + +https://support.office.com/en-us/article/list-of-field-codes-in-word-1ad6d91a-55a7-4a8d-b535-cf7888659a51#top + + +**wdFieldAddin** + 81 -- Add-in field. Not available through the Field dialog box. Used to store data that is hidden from the user interface. +**wdFieldAddressBlock** + 93 AddressBlock field. +**wdFieldAdvance** + 84 Advance field. +**wdFieldAsk** + 38 Ask field. +**wdFieldAuthor** + 17 Author field. +**wdFieldAutoNum** + 54 AutoNum field. +**wdFieldAutoNumLegal** + 53 AutoNumLgl field. +**wdFieldAutoNumOutline** + 52 AutoNumOut field. +**wdFieldAutoText** + 79 AutoText field. +**wdFieldAutoTextList** + 89 AutoTextList field. +**wdFieldBarCode** + 63 BarCode field. +**wdFieldBidiOutline** + 92 BidiOutline field. +**wdFieldComments** + 19 Comments field. +**wdFieldCompare** + 80 Compare field. +**wdFieldCreateDate** + 21 CreateDate field. +**wdFieldData** + 40 Data field. +**wdFieldDatabase** + 78 Database field. +**wdFieldDate** + 31 Date field. +**wdFieldDDE** + 45 DDE field. No longer available through the Field dialog box, + but supported for documents created in earlier versions of Word. +**wdFieldDDEAuto** + 46 DDEAuto field. No longer available through the Field dialog box, + but supported for documents created in earlier versions of Word. +**wdFieldDisplayBarcode** + 99 DisplayBarcode field. +**wdFieldDocProperty** + 85 DocProperty field. +**wdFieldDocVariable** + 64 DocVariable field. +**wdFieldEditTime** + 25 EditTime field. +**wdFieldEmbed** + 58 Embedded field. +**wdFieldEmpty** + -1 Empty field. Acts as a placeholder for field content that has not yet + been added. A field added by pressing Ctrl+F9 in the user interface + is an Empty field. +**wdFieldExpression** + 34 = (Formula) field. +**wdFieldFileName** + 29 FileName field. +**wdFieldFileSize** + 69 FileSize field. +**wdFieldFillIn** + 39 Fill-In field. +**wdFieldFootnoteRef** + 5 FootnoteRef field. Not available through the Field dialog box. + Inserted programmatically or interactively. +**wdFieldFormCheckBox** + 71 FormCheckBox field. +**wdFieldFormDropDown** + 83 FormDropDown field. +**wdFieldFormTextInput** + 70 FormText field. +**wdFieldFormula** + 49 EQ (Equation) field. +**wdFieldGlossary** + 47 Glossary field. No longer supported in Word. +**wdFieldGoToButton** + 50 GoToButton field. +**wdFieldGreetingLine** + 94 GreetingLine field. +**wdFieldHTMLActiveX** + 91 HTMLActiveX field. Not currently supported. +**wdFieldHyperlink** + 88 Hyperlink field. +**wdFieldIf** + 7 If field. +**wdFieldImport** + 55 Import field. Cannot be added through the Field dialog box, + but can be added interactively or through code. +**wdFieldInclude** + 36 Include field. Cannot be added through the Field dialog box, + but can be added interactively or through code. +**wdFieldIncludePicture** + 67 IncludePicture field. +**wdFieldIncludeText** + 68 IncludeText field. +**wdFieldIndex** + 8 Index field. +**wdFieldIndexEntry** + 4 XE (Index Entry) field. +**wdFieldInfo** + 14 Info field. +**wdFieldKeyWord** + 18 Keywords field. +**wdFieldLastSavedBy** + 20 LastSavedBy field. +**wdFieldLink** + 56 Link field. +**wdFieldListNum** + 90 ListNum field. +**wdFieldMacroButton** + 51 MacroButton field. +**wdFieldMergeBarcode** + 98 MergeBarcode field. +**wdFieldMergeField** + 59 MergeField field. +**wdFieldMergeRec** + 44 MergeRec field. +**wdFieldMergeSeq** + 75 MergeSeq field. +**wdFieldNext** + 41 Next field. +**wdFieldNextIf** + 42 NextIf field. +**wdFieldNoteRef** + 72 NoteRef field. +**wdFieldNumChars** + 28 NumChars field. +**wdFieldNumPages** + 26 NumPages field. +**wdFieldNumWords** + 27 NumWords field. +**wdFieldOCX** + 87 OCX field. Cannot be added through the Field dialog box, but can be + added through code by using the AddOLEControl method of the Shapes + collection or of the InlineShapes collection. +**wdFieldPage** + 33 Page field. +**wdFieldPageRef** + 37 PageRef field. +**wdFieldPrint** + 48 Print field. +**wdFieldPrintDate** + 23 PrintDate field. +**wdFieldPrivate** + 77 Private field. +**wdFieldQuote** + 35 Quote field. +**wdFieldRef** + 3 Ref field. +**wdFieldRefDoc** + 11 RD (Reference Document) field. +**wdFieldRevisionNum** + 24 RevNum field. +**wdFieldSaveDate** + 22 SaveDate field. +**wdFieldSection** + 65 Section field. +**wdFieldSectionPages** + 66 SectionPages field. +**wdFieldSequence** + 12 Seq (Sequence) field. +**wdFieldSet** + 6 Set field. +**wdFieldShape** + 95 Shape field. Automatically created for any drawn picture. +**wdFieldSkipIf** + 43 SkipIf field. +**wdFieldStyleRef** + 10 StyleRef field. +**wdFieldSubject** + 16 Subject field. +**wdFieldSubscriber** + 82 Macintosh only. For information about this constant, consult the language reference Help included with Microsoft Office Macintosh Edition. +**wdFieldSymbol** + 57 Symbol field. +**wdFieldTemplate** + 30 Template field. +**wdFieldTime** + 32 Time field. +**wdFieldTitle** + 15 Title field. +**wdFieldTOA** + 73 TOA (Table of Authorities) field. +**wdFieldTOAEntry** + 74 TOA (Table of Authorities Entry) field. +**wdFieldTOC** + 13 TOC (Table of Contents) field. +**wdFieldTOCEntry** + 9 TOC (Table of Contents Entry) field. +**wdFieldUserAddress** + 62 UserAddress field. +**wdFieldUserInitials** + 61 UserInitials field. +**wdFieldUserName** + 60 UserName field. +**wdFieldBibliography** + 97 Bibliography field. +**wdFieldCitation** + 96 Citation field. + + +WdFieldKind +~~~~~~~~~~~ + +**wdFieldKindCold** + 3 A field that doesn't have a result, for example, an Index Entry (XE), + Table of Contents Entry (TC), or Private field. + +**wdFieldKindHot** + 1 A field that's automatically updated each time it's displayed or each time + the page is reformatted, but which can also be manually updated + (for example, INCLUDEPICTURE or FORMDROPDOWN). + +**wdFieldKindNone** + 0 An invalid field (for example, a pair of field characters with nothing inside). + +**wdFieldKindWarm** + 2 A field that can be updated and has a result. This type includes fields + that are automatically updated when the source changes as well as fields + that can be manually updated (for example, DATE or INCLUDETEXT). + +XML specimens +------------- + +Example use of a simple field. + +.. highlight:: xml + + + + Example Document.docx + + + +Example use of a complex field character: + +.. highlight:: xml + + + + + + AUTHOR + + + + + + Rex Jaeschke + + + + + +Example of a locked field code: + +.. highlight:: xml + + + + + … + + + + field result + + + + + +The type attribute value of separate specifies +that this is a complex field separator character; therefore it is being used +to separate the field codes from the field contents in a complex field. + +Example: +.. highlight:: xml + + + +Example: Dirty element + + + + + + + /l 1-3 + + + + + +XML semantics - CT_FldChar +-------------------------- + +* The `w:instrText` element specifies the field codes for the simple field. + +* The `w:fldCharType` element specifies the type of the current complex field + character in the document. + The possible values for this attribute are defined by the `ST_FldCharType` + simple type + +* The `w:fldLock` element prevents the field code from updating. + The possible values for this attribute are defined by the ST_OnOff simple type + +* The `w:dirty` flags that the element needs updating. + +* The parent element is `w:r` - run element + +* Possible child element is `ffData` (Form Field Properties) + +* If a complex field character is located in an inappropriate location in a + WordprocessingML document, then its presence shall be ignored and no field + shall be present in the resulting document when displayed. + +* If a complex field is not closed before the end of a document story, then no + field shall be generated and each individual run shall be processed as if the + field characters did not exist. + +XML semantics - CT_SimpleField +------------------------------ + +* The semantics of this field are defined via its field codes + + +Related Schema Definitions +-------------------------- + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From f221f3e19cdcea45cd1f49e04ab12d12801ee0fa Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Tue, 7 Aug 2018 21:06:44 +0200 Subject: [PATCH 47/52] flds: first stab at working version --- docx/oxml/__init__.py | 9 ++++++--- docx/oxml/simpletypes.py | 13 ++++++++++++- docx/oxml/text/run.py | 37 +++++++++++++++++++++++++++++++++---- docx/text/paragraph.py | 23 +++++++++++++++++++++++ 4 files changed, 74 insertions(+), 8 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 1fce04d2a..78013810f 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -83,11 +83,11 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): from docx.oxml.endnotes import CT_Endnotes # noqa -register_element_cls('w:endnotes', CT_Endnotes) +register_element_cls("w:endnotes", CT_Endnotes) from docx.oxml.footnotes import CT_Footnotes # noqa -register_element_cls('w:footnotes', CT_Footnotes) +register_element_cls("w:footnotes", CT_Footnotes) from docx.oxml.numbering import CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr # noqa @@ -269,8 +269,11 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): 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 +from docx.oxml.text.run import CT_Br, CT_R, CT_Text, CT_SimpleField, CT_FldChar # 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:fldSimple", CT_SimpleField) +register_element_cls("w:fldChar", CT_FldChar) + diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 085cc6fd0..44d3eefbe 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -184,7 +184,6 @@ class XsdUnsignedLong(BaseIntType): def validate(cls, value): cls.validate_int_in_range(value, 0, 18446744073709551615) - class ST_BrClear(XsdString): @classmethod @@ -407,3 +406,15 @@ class ST_VerticalAlignRun(XsdStringEnumeration): SUBSCRIPT = 'subscript' _members = (BASELINE, SUPERSCRIPT, SUBSCRIPT) + + +class ST_FldCharType(XsdString): + @classmethod + def validate(cls, value): + cls.validate_string(value) + valid_values = ('begin', 'separate', 'end') + if value not in valid_values: + raise ValueError( + "must be one of %s, got '%s'" % (valid_values, value) + ) + diff --git a/docx/oxml/text/run.py b/docx/oxml/text/run.py index 8f0a62e82..9c61ccdb4 100644 --- a/docx/oxml/text/run.py +++ b/docx/oxml/text/run.py @@ -5,10 +5,37 @@ """ from ..ns import qn -from ..simpletypes import ST_BrClear, ST_BrType -from ..xmlchemy import ( - BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne -) +from ..simpletypes import ST_BrClear, ST_BrType, ST_OnOff, ST_String, ST_FldCharType +from ..xmlchemy import (BaseOxmlElement, OptionalAttribute, RequiredAttribute, + ZeroOrMore, ZeroOrOne) + + +class CT_SimpleField(BaseOxmlElement): + """ + `` element, indicating a simple field character. + """ + instr = RequiredAttribute("w:instr", ST_String) + fldLock = OptionalAttribute('w:fldLock', ST_OnOff) + dirty = OptionalAttribute('w:dirty', ST_OnOff) + + def set_field(self, code): + self.instr = code + +class CT_FldChar(BaseOxmlElement): + """ + `` element, indicating a simple field character. + """ + fldCharType = RequiredAttribute("w:fldCharType", ST_FldCharType) + instrText = RequiredAttribute("w:instrText", ST_String) + fldLock = OptionalAttribute('w:fldLock', ST_OnOff) + dirty = OptionalAttribute('w:dirty', ST_OnOff) + r = ZeroOrMore('w:r') + + def set_field(self, codes): + self.add_r() + for code in codes: + self.instrText = code + class CT_Br(BaseOxmlElement): @@ -29,6 +56,8 @@ class CT_R(BaseOxmlElement): cr = ZeroOrMore('w:cr') tab = ZeroOrMore('w:tab') drawing = ZeroOrMore('w:drawing') + fldsimple = ZeroOrMore('w:fldSimple') + fldChar = ZeroOrMore('w:fldChar') def _insert_rPr(self, rPr): self.insert(0, rPr) diff --git a/docx/text/paragraph.py b/docx/text/paragraph.py index 0aec19782..81a8fa402 100644 --- a/docx/text/paragraph.py +++ b/docx/text/paragraph.py @@ -39,6 +39,29 @@ def add_run(self, text=None, style=None): run.style = style return run + def add_simplefield(self, fieldcode): + r = self._p.add_r() + fld = r._add_fldsimple() + fld.set_field(fieldcode) + return fld + + def add_complexfield(self, fieldcode): + r = self._p.add_r() + fld_begin = r._add_fldChar() + fld_begin.fldCharType = "begin" + + r = self._p.add_r() + fld = r._add_fldChar() + fld.set_field(fieldcode) + r = self._p.add_r() + fld_sep = r._add_fldChar() + fld_sep.fldCharType = "separate" + + r = self._p.add_r() + fld_end = r._add_fldChar() + fld_end.fldCharType = "end" + return fld + @property def alignment(self): """ From d7e16b4c70befd801ce9820eb0dc1760ec1f9841 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Mon, 20 Aug 2018 17:11:52 +0200 Subject: [PATCH 48/52] flds: Added enumeration and basic functionality --- docx/enum/fields.py | 627 +++++++++++++++++++++++++++++++++++++++++ docx/oxml/text/run.py | 12 +- docx/text/paragraph.py | 6 +- docx/text/run.py | 5 + 4 files changed, 640 insertions(+), 10 deletions(-) create mode 100644 docx/enum/fields.py diff --git a/docx/enum/fields.py b/docx/enum/fields.py new file mode 100644 index 000000000..7f662b4e0 --- /dev/null +++ b/docx/enum/fields.py @@ -0,0 +1,627 @@ +# encoding: utf-8 + +""" +Enumerations related to text in WordprocessingML files +""" + +from __future__ import absolute_import, print_function, unicode_literals + +from .base import alias, EnumMember, XmlEnumeration, XmlMappedEnumMember + + +@alias('WD_FIELDCODE') +class WD_FIELD_TYPE(XmlEnumeration): + """ + Specifies a standard preset color to apply. Used for font highlighting and + perhaps other applications. + """ + + __ms_name__ = 'WdFieldType' + + __url__ = 'https://msdn.microsoft.com/en-us/vba/word-vba/articles/wdfieldtype-enumeration-word' + + __members__ = ( + EnumMember( + 'ADDIN', 81, 'Add-in field. Not available through the Field dialog' + 'box. Used to store data that is hidden from the user interface.' + ), + EnumMember( + 'ADVANCE', 84, 'Advance field.' + ), + EnumMember( + 'ASK', 38, 'Ask field.' + ), + EnumMember( + 'AUTHOR', 17, 'Author field.' + ), + EnumMember( + 'AUTONUM', 54, 'AutoNum field.' + ), + EnumMember( + 'AUTONUMLEGAL', 53, 'AutoNumLgl field.' + ), + EnumMember( + 'AUTONUMOUTLINE', 52, 'AutoNumOut field.' + ), + EnumMember( + 'AUTOTEXT', 79, 'AutoText field.' + ), + EnumMember( + 'AUTOTEXTLIST', 89, 'AutoTextList field.' + ), + EnumMember( + 'BARCODE', 63, 'BarCode field.' + ), + EnumMember( + 'BIDIOUTLINE', 92, 'BidiOutline field.' + ), + EnumMember( + 'COMMENTS', 19, 'Comments field.' + ), + EnumMember( + 'COMPARE', 80, 'Compare field.' + ), + EnumMember( + 'CREATEDATE', 21, 'CreateDate field.' + ), + EnumMember( + 'DATA', 40, 'Data field.' + ), + EnumMember( + 'DATABASE', 78, 'Database field.' + ), + EnumMember( + 'DATE', 31, 'Date field.' + ), + EnumMember( + 'DDE', 45, 'DDE field. No longer available through the Field' + 'dialog box, but supported for documents created in earlier' + 'versions of Word.' + ), + EnumMember( + 'DDEAUTO', 46, 'DDEAuto field. No longer available through the' + 'Field dialog box, but supported for documents created in earlier' + 'versions of Word.' + ), + EnumMember( + 'DISPLAYBARCODE', 99, 'DisplayBarcode field.' + ), + EnumMember( + 'DOCPROPERTY', 85, 'DocProperty field.' + ), + EnumMember( + 'DOCVARIABLE', 64, 'DocVariable field.' + ), + EnumMember( + 'EDITTIME', 25, 'EditTime field.' + ), + EnumMember( + 'EMBED', 58, 'Embedded field.' + ), + EnumMember( + 'EMPTY', -1, 'Empty field. Acts as a placeholder for field content' + 'that has not yet been added. A field added by pressing Ctrl+F9 in' + 'the user interface is an Empty field.' + ), + EnumMember( + 'EXPRESSION', 34, '= (Formula) field.' + ), + EnumMember( + 'FILENAME', 29, 'FileName field.' + ), + EnumMember( + 'FILESIZE', 69, 'FileSize field.' + ), + EnumMember( + 'FILLIN', 39, 'Fill-In field.' + ), + EnumMember( + 'FOOTNOTEREF', 5, 'FootnoteRef field. Not available through the' + 'Field dialog box. Inserted programmatically or interactively.' + ), + EnumMember( + 'FORMCHECKBOX', 71, 'FormCheckBox field.' + ), + EnumMember( + 'FORMDROPDOWN', 83, 'FormDropDown field.' + ), + EnumMember( + 'FORMTEXTINPUT', 70, 'FormText field.' + ), + EnumMember( + 'FORMULA', 49, 'EQ (Equation) field.' + ), + EnumMember( + 'GLOSSARY', 47, 'Glossary field. No longer supported in Word.' + ), + EnumMember( + 'GOTOBUTTON', 50, 'GoToButton field.' + ), + EnumMember( + 'GREETINGLINE', 94, 'GreetingLine field.' + ), + EnumMember( + 'HTMLACTIVEX', 91, 'HTMLActiveX field. Not currently supported.' + ), + EnumMember( + 'HYPERLINK', 88, 'Hyperlink field.' + ), + EnumMember( + 'IF', 7, 'If field.' + ), + EnumMember( + 'IMPORT', 55, 'Import field. Cannot be added through the Field' + 'dialog box, but can be added interactively or through code.' + ), + EnumMember( + 'INCLUDE', 36, 'Include field. Cannot be added through the Field' + 'dialog box, but can be added interactively or through code.' + ), + EnumMember( + 'INCLUDEPICTURE', 67, 'IncludePicture field.' + ), + EnumMember( + 'INCLUDETEXT', 68, 'IncludeText field.' + ), + EnumMember( + 'INDEX', 8, 'Index field.' + ), + EnumMember( + 'INDEXENTRY', 4, 'XE (Index Entry) field.' + ), + EnumMember( + 'INFO', 14, 'Info field.' + ), + EnumMember( + 'KEYWORD', 18, 'Keywords field.' + ), + EnumMember( + 'LASTSAVEDBY', 20, 'LastSavedBy field.' + ), + EnumMember( + 'LINK', 56, 'Link field.' + ), + EnumMember( + 'LISTNUM', 90, 'ListNum field.' + ), + EnumMember( + 'MACROBUTTON', 51, 'MacroButton field.' + ), + EnumMember( + 'MERGEBARCODE', 98, 'MergeBarcode field.' + ), + EnumMember( + 'MERGEFIELD', 59, 'MergeField field.' + ), + EnumMember( + 'MERGEREC', 44, 'MergeRec field.' + ), + EnumMember( + 'MERGESEQ', 75, 'MergeSeq field.' + ), + EnumMember( + 'NEXT', 41, 'Next field.' + ), + EnumMember( + 'NEXTIF', 42, 'NextIf field.' + ), + EnumMember( + 'NOTEREF', 72, 'NoteRef field.' + ), + EnumMember( + 'NUMCHARS', 28, 'NumChars field.' + ), + EnumMember( + 'NUMPAGES', 26, 'NumPages field.' + ), + EnumMember( + 'NUMWORDS', 27, 'NumWords field.' + ), + EnumMember( + 'OCX', 87, 'OCX field. Cannot be added through the Field dialog' + 'box, but can be added through code by using the AddOLEControl' + 'method of the Shapes collection or of the InlineShapes collection' + ), + EnumMember( + 'PAGE', 33, 'Page field.' + ), + EnumMember( + 'PAGEREF', 37, 'PageRef field.' + ), + EnumMember( + 'PRINT', 48, 'Print field.' + ), + EnumMember( + 'PRINTDATE', 23, 'PrintDate field.' + ), + EnumMember( + 'PRIVATE', 77, 'Private field.' + ), + EnumMember( + 'QUOTE', 35, 'Quote field.' + ), + EnumMember( + 'REF', 3, 'Ref field.' + ), + EnumMember( + 'REFDOC', 11, 'RD (Reference Document) field.' + ), + EnumMember( + 'REVISIONNUM', 24, 'RevNum field.' + ), + EnumMember( + 'SAVEDATE', 22, 'SaveDate field.' + ), + EnumMember( + 'SECTION', 65, 'Section field.' + ), + EnumMember( + 'SECTIONPAGES', 66, 'SectionPages field.' + ), + EnumMember( + 'SEQUENCE', 12, 'Seq (Sequence) field.' + ), + EnumMember( + 'SET', 6, 'Set field.' + ), + EnumMember( + 'SHAPE', 95, 'Shape field. Automatically created for any drawn' + 'picture.' + ), + EnumMember( + 'SKIPIF', 43, 'SkipIf field.' + ), + EnumMember( + 'STYLEREF', 10, 'StyleRef field.' + ), + EnumMember( + 'SUBJECT', 16, 'Subject field.' + ), + EnumMember( + 'SUBSCRIBER', 82, 'Macintosh only. For information about this' + 'constant, consult the language reference Help included with' + 'Microsoft Office Macintosh Edition.' + ), + EnumMember( + 'SYMBOL', 57, 'Symbol field.' + ), + EnumMember( + 'TEMPLATE', 30, 'Template field.' + ), + EnumMember( + 'TIME', 32, 'Time field.' + ), + EnumMember( + 'TITLE', 15, 'Title field.' + ), + EnumMember( + 'TOA', 73, 'TOA (Table of Authorities) field.' + ), + EnumMember( + 'TOAENTRY', 74, 'TOA (Table of Authorities Entry) field.' + ), + EnumMember( + 'TOC', 13, 'TOC (Table of Contents) field.' + ), + EnumMember( + 'TOCENTRY', 9, 'TOC (Table of Contents Entry) field.' + ), + EnumMember( + 'USERADDRESS', 62, 'UserAddress field.' + ), + EnumMember( + 'USERINITIALS', 61, 'UserInitials field.' + ), + EnumMember( + 'USERNAME', 60, 'UserName field.' + ), + EnumMember( + 'BIBLIOGRAPHY', 97, 'Bibliography field.' + ), + EnumMember( + 'CITATION', 96, 'Citation field.' + ), + EnumMember( + 'ADDIN', 81, 'Add-in field. Not available through the Field dialog' + 'box. Used to store data that is hidden from the user interface.' + ), + EnumMember( + 'ADDRESSBLOCK', 93, 'AddressBlock field.' + ), + EnumMember( + 'ADVANCE', 84, 'Advance field.' + ), + EnumMember( + 'ASK', 38, 'Ask field.' + ), + EnumMember( + 'AUTHOR', 17, 'Author field.' + ), + EnumMember( + 'AUTONUM', 54, 'AutoNum field.' + ), + EnumMember( + 'AUTONUMLEGAL', 53, 'AutoNumLgl field.' + ), + EnumMember( + 'AUTONUMOUTLINE', 52, 'AutoNumOut field.' + ), + EnumMember( + 'AUTOTEXT', 79, 'AutoText field.' + ), + EnumMember( + 'AUTOTEXTLIST', 89, 'AutoTextList field.' + ), + EnumMember( + 'BARCODE', 63, 'BarCode field.' + ), + EnumMember( + 'BIDIOUTLINE', 92, 'BidiOutline field.' + ), + EnumMember( + 'COMMENTS', 19, 'Comments field.' + ), + EnumMember( + 'COMPARE', 80, 'Compare field.' + ), + EnumMember( + 'CREATEDATE', 21, 'CreateDate field.' + ), + EnumMember( + 'DATA', 40, 'Data field.' + ), + EnumMember( + 'DATABASE', 78, 'Database field.' + ), + EnumMember( + 'DATE', 31, 'Date field.' + ), + EnumMember( + 'DDE', 45, 'DDE field. No longer available through the Field' + 'dialog box, but supported for documents created in earlier' + 'versions of Word.' + ), + EnumMember( + 'DDEAUTO', 46, 'DDEAuto field. No longer available through the' + 'Field dialog box, but supported for documents created in earlier' + 'versions of Word.' + ), + EnumMember( + 'DISPLAYBARCODE', 99, 'DisplayBarcode field.' + ), + EnumMember( + 'DOCPROPERTY', 85, 'DocProperty field.' + ), + EnumMember( + 'DOCVARIABLE', 64, 'DocVariable field.' + ), + EnumMember( + 'EDITTIME', 25, 'EditTime field.' + ), + EnumMember( + 'EMBED', 58, 'Embedded field.' + ), + EnumMember( + 'EMPTY', -1, 'Empty field. Acts as a placeholder for field content' + 'that has not yet been added. A field added by pressing Ctrl+F9 in' + 'the user interface is an Empty field.' + ), + EnumMember( + 'EXPRESSION', 34, '= (Formula) field.' + ), + EnumMember( + 'FILENAME', 29, 'FileName field.' + ), + EnumMember( + 'FILESIZE', 69, 'FileSize field.' + ), + EnumMember( + 'FILLIN', 39, 'Fill-In field.' + ), + EnumMember( + 'FOOTNOTEREF', 5, 'FootnoteRef field. Not available through the' + 'Field dialog box. Inserted programmatically or interactively.' + ), + EnumMember( + 'FORMCHECKBOX', 71, 'FormCheckBox field.' + ), + EnumMember( + 'FORMDROPDOWN', 83, 'FormDropDown field.' + ), + EnumMember( + 'FORMTEXTINPUT', 70, 'FormText field.' + ), + EnumMember( + 'FORMULA', 49, 'EQ (Equation) field.' + ), + EnumMember( + 'GLOSSARY', 47, 'Glossary field. No longer supported in Word.' + ), + EnumMember( + 'GOTOBUTTON', 50, 'GoToButton field.' + ), + EnumMember( + 'GREETINGLINE', 94, 'GreetingLine field.' + ), + EnumMember( + 'HTMLACTIVEX', 91, 'HTMLActiveX field. Not currently supported.' + ), + EnumMember( + 'HYPERLINK', 88, 'Hyperlink field.' + ), + EnumMember( + 'IF', 7, 'If field.' + ), + EnumMember( + 'IMPORT', 55, 'Import field. Cannot be added through the Field' + 'dialog box, but can be added interactively or through code.' + ), + EnumMember( + 'INCLUDE', 36, 'Include field. Cannot be added through the Field' + 'dialog box, but can be added interactively or through code.' + ), + EnumMember( + 'INCLUDEPICTURE', 67, 'IncludePicture field.' + ), + EnumMember( + 'INCLUDETEXT', 68, 'IncludeText field.' + ), + EnumMember( + 'INDEX', 8, 'Index field.' + ), + EnumMember( + 'INDEXENTRY', 4, 'XE (Index Entry) field.' + ), + EnumMember( + 'INFO', 14, 'Info field.' + ), + EnumMember( + 'KEYWORD', 18, 'Keywords field.' + ), + EnumMember( + 'LASTSAVEDBY', 20, 'LastSavedBy field.' + ), + EnumMember( + 'LINK', 56, 'Link field.' + ), + EnumMember( + 'LISTNUM', 90, 'ListNum field.' + ), + EnumMember( + 'MACROBUTTON', 51, 'MacroButton field.' + ), + EnumMember( + 'MERGEBARCODE', 98, 'MergeBarcode field.' + ), + EnumMember( + 'MERGEFIELD', 59, 'MergeField field.' + ), + EnumMember( + 'MERGEREC', 44, 'MergeRec field.' + ), + EnumMember( + 'MERGESEQ', 75, 'MergeSeq field.' + ), + EnumMember( + 'NEXT', 41, 'Next field.' + ), + EnumMember( + 'NEXTIF', 42, 'NextIf field.' + ), + EnumMember( + 'NOTEREF', 72, 'NoteRef field.' + ), + EnumMember( + 'NUMCHARS', 28, 'NumChars field.' + ), + EnumMember( + 'NUMPAGES', 26, 'NumPages field.' + ), + EnumMember( + 'NUMWORDS', 27, 'NumWords field.' + ), + EnumMember( + 'OCX', 87, 'OCX field. Cannot be added through the Field dialog' + 'box, but can be added through code by using the AddOLEControl' + 'method of the Shapes collection or of the InlineShapes collection' + ), + EnumMember( + 'PAGE', 33, 'Page field.' + ), + EnumMember( + 'PAGEREF', 37, 'PageRef field.' + ), + EnumMember( + 'PRINT', 48, 'Print field.' + ), + EnumMember( + 'PRINTDATE', 23, 'PrintDate field.' + ), + EnumMember( + 'PRIVATE', 77, 'Private field.' + ), + EnumMember( + 'QUOTE', 35, 'Quote field.' + ), + EnumMember( + 'REF', 3, 'Ref field.' + ), + EnumMember( + 'REFDOC', 11, 'RD (Reference Document) field.' + ), + EnumMember( + 'REVISIONNUM', 24, 'RevNum field.' + ), + EnumMember( + 'SAVEDATE', 22, 'SaveDate field.' + ), + EnumMember( + 'SECTION', 65, 'Section field.' + ), + EnumMember( + 'SECTIONPAGES', 66, 'SectionPages field.' + ), + EnumMember( + 'SEQ', 12, 'Seq (Sequence) field.' + ), + EnumMember( + 'SET', 6, 'Set field.' + ), + EnumMember( + 'SHAPE', 95, 'Shape field. Automatically created for any drawn' + 'picture.' + ), + EnumMember( + 'SKIPIF', 43, 'SkipIf field.' + ), + EnumMember( + 'STYLEREF', 10, 'StyleRef field.' + ), + EnumMember( + 'SUBJECT', 16, 'Subject field.' + ), + EnumMember( + 'SUBSCRIBER', 82, 'Macintosh only. For information about this' + 'constant, consult the language reference Help included with' + 'Microsoft Office Macintosh Edition.' + ), + EnumMember( + 'SYMBOL', 57, 'Symbol field.' + ), + EnumMember( + 'TEMPLATE', 30, 'Template field.' + ), + EnumMember( + 'TIME', 32, 'Time field.' + ), + EnumMember( + 'TITLE', 15, 'Title field.' + ), + EnumMember( + 'TOA', 73, 'TOA (Table of Authorities) field.' + ), + EnumMember( + 'TOAENTRY', 74, 'TOA (Table of Authorities Entry) field.' + ), + EnumMember( + 'TOC', 13, 'TOC (Table of Contents) field.' + ), + EnumMember( + 'TOCENTRY', 9, 'TOC (Table of Contents Entry) field.' + ), + EnumMember( + 'USERADDRESS', 62, 'UserAddress field.' + ), + EnumMember( + 'USERINITIALS', 61, 'UserInitials field.' + ), + EnumMember( + 'USERNAME', 60, 'UserName field.' + ), + EnumMember( + 'BIBLIOGRAPHY', 97, 'Bibliography field.' + ), + EnumMember( + 'CITATION', 96, 'Citation field.' + ), +) diff --git a/docx/oxml/text/run.py b/docx/oxml/text/run.py index 9c61ccdb4..1e294d002 100644 --- a/docx/oxml/text/run.py +++ b/docx/oxml/text/run.py @@ -8,6 +8,7 @@ from ..simpletypes import ST_BrClear, ST_BrType, ST_OnOff, ST_String, ST_FldCharType from ..xmlchemy import (BaseOxmlElement, OptionalAttribute, RequiredAttribute, ZeroOrMore, ZeroOrOne) +from docx.enum.fields import WD_FIELDCODE class CT_SimpleField(BaseOxmlElement): @@ -18,8 +19,9 @@ class CT_SimpleField(BaseOxmlElement): fldLock = OptionalAttribute('w:fldLock', ST_OnOff) dirty = OptionalAttribute('w:dirty', ST_OnOff) - def set_field(self, code): - self.instr = code + def set_field(self, field_name, properties): + if getattr(WD_FIELDCODE, field_name): + self.instr = field_name + ' ' + properties class CT_FldChar(BaseOxmlElement): """ @@ -29,13 +31,9 @@ class CT_FldChar(BaseOxmlElement): instrText = RequiredAttribute("w:instrText", ST_String) fldLock = OptionalAttribute('w:fldLock', ST_OnOff) dirty = OptionalAttribute('w:dirty', ST_OnOff) - r = ZeroOrMore('w:r') def set_field(self, codes): - self.add_r() - for code in codes: - self.instrText = code - + self.instrText = code class CT_Br(BaseOxmlElement): diff --git a/docx/text/paragraph.py b/docx/text/paragraph.py index 81a8fa402..45d7d4919 100644 --- a/docx/text/paragraph.py +++ b/docx/text/paragraph.py @@ -39,10 +39,10 @@ def add_run(self, text=None, style=None): run.style = style return run - def add_simplefield(self, fieldcode): + def add_simplefield(self, field_name, properties): r = self._p.add_r() - fld = r._add_fldsimple() - fld.set_field(fieldcode) + run = Run(r, self) + fld = run.add_field(field_name, properties) return fld def add_complexfield(self, fieldcode): diff --git a/docx/text/run.py b/docx/text/run.py index 97d6da7db..d6c3c1369 100644 --- a/docx/text/run.py +++ b/docx/text/run.py @@ -25,6 +25,11 @@ def __init__(self, r, parent): super(Run, self).__init__(parent) self._r = self._element = self.element = r + def add_field(self, field_name, properties): + fld = self._r.add_fldsimple() + fld.set_field(field_name, properties) + return fld + def add_break(self, break_type=WD_BREAK.LINE): """ Add a break element of *break_type* to this run. *break_type* can From 590eececcf052abbd26a93c07f45e0fae5cbb558 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Sat, 7 Sep 2019 19:03:43 +0200 Subject: [PATCH 49/52] spike: study field implementation --- docx/enum/fields.py | 4 +- docx/oxml/__init__.py | 2 +- docx/oxml/simpletypes.py | 132 +++++++++++++++------------------------ docx/oxml/text/run.py | 102 ++++++++++++++++++++---------- docx/text/fields.py | 124 ++++++++++++++++++++++++++++++++++++ docx/text/paragraph.py | 29 +++------ docx/text/run.py | 18 +++--- 7 files changed, 263 insertions(+), 148 deletions(-) create mode 100644 docx/text/fields.py diff --git a/docx/enum/fields.py b/docx/enum/fields.py index 7f662b4e0..560157e33 100644 --- a/docx/enum/fields.py +++ b/docx/enum/fields.py @@ -12,8 +12,8 @@ @alias('WD_FIELDCODE') class WD_FIELD_TYPE(XmlEnumeration): """ - Specifies a standard preset color to apply. Used for font highlighting and - perhaps other applications. + Specifies a Microsoft Word field. Unless otherwise specified, the field types + described in this enumeration can be added to a Word document. """ __ms_name__ = 'WdFieldType' diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 78013810f..d59d6b15c 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -276,4 +276,4 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls("w:t", CT_Text) register_element_cls("w:fldSimple", CT_SimpleField) register_element_cls("w:fldChar", CT_FldChar) - +register_element_cls("w:instrText", CT_Text) diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 44d3eefbe..51b1a30e8 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -6,16 +6,13 @@ type in the associated XML schema. """ -from __future__ import ( - absolute_import, division, print_function, unicode_literals -) +from __future__ import absolute_import, division, print_function, unicode_literals -from ..exceptions import InvalidXmlError -from ..shared import Emu, Pt, RGBColor, Twips +from docx.exceptions import InvalidXmlError +from docx.shared import Emu, Pt, RGBColor, Twips class BaseSimpleType(object): - @classmethod def from_xml(cls, str_value): return cls.convert_from_xml(str_value) @@ -29,17 +26,15 @@ def to_xml(cls, value): @classmethod def validate_int(cls, value): if not isinstance(value, int): - raise TypeError( - "value must be , got %s" % type(value) - ) + raise TypeError("value must be , got %s" % type(value)) @classmethod def validate_int_in_range(cls, value, min_inclusive, max_inclusive): cls.validate_int(value) if value < min_inclusive or value > max_inclusive: raise ValueError( - "value must be in range %d to %d inclusive, got %d" % - (min_inclusive, max_inclusive, value) + "value must be in range %d to %d inclusive, got %d" + % (min_inclusive, max_inclusive, value) ) @classmethod @@ -51,13 +46,10 @@ def validate_string(cls, value): return value except NameError: # means we're on Python 3 pass - raise TypeError( - "value must be a string, got %s" % type(value) - ) + raise TypeError("value must be a string, got %s" % type(value)) class BaseIntType(BaseSimpleType): - @classmethod def convert_from_xml(cls, str_value): return int(str_value) @@ -72,7 +64,6 @@ def validate(cls, value): class BaseStringType(BaseSimpleType): - @classmethod def convert_from_xml(cls, str_value): return str_value @@ -87,14 +78,11 @@ def validate(cls, value): class BaseStringEnumerationType(BaseStringType): - @classmethod def validate(cls, value): cls.validate_string(value) if value not in cls._members: - raise ValueError( - "must be one of %s, got '%s'" % (cls._members, value) - ) + raise ValueError("must be one of %s, got '%s'" % (cls._members, value)) class XsdAnyUri(BaseStringType): @@ -106,19 +94,17 @@ class XsdAnyUri(BaseStringType): class XsdBoolean(BaseSimpleType): - @classmethod def convert_from_xml(cls, str_value): - if str_value not in ('1', '0', 'true', 'false'): + if str_value not in ("1", "0", "true", "false"): raise InvalidXmlError( - "value must be one of '1', '0', 'true' or 'false', got '%s'" - % str_value + "value must be one of '1', '0', 'true' or 'false', got '%s'" % str_value ) - return str_value in ('1', 'true') + return str_value in ("1", "true") @classmethod def convert_to_xml(cls, value): - return {True: '1', False: '0'}[value] + return {True: "1", False: "0"}[value] @classmethod def validate(cls, value): @@ -134,23 +120,20 @@ class XsdId(BaseStringType): String that must begin with a letter or underscore and cannot contain any colons. Not fully validated because not used in external API. """ + pass class XsdInt(BaseIntType): - @classmethod def validate(cls, value): cls.validate_int_in_range(value, -2147483648, 2147483647) class XsdLong(BaseIntType): - @classmethod def validate(cls, value): - cls.validate_int_in_range( - value, -9223372036854775808, 9223372036854775807 - ) + cls.validate_int_in_range(value, -9223372036854775808, 9223372036854775807) class XsdString(BaseStringType): @@ -168,51 +151,44 @@ class XsdToken(BaseStringType): xsd:string with whitespace collapsing, e.g. multiple spaces reduced to one, leading and trailing space stripped. """ + pass class XsdUnsignedInt(BaseIntType): - @classmethod def validate(cls, value): cls.validate_int_in_range(value, 0, 4294967295) class XsdUnsignedLong(BaseIntType): - @classmethod def validate(cls, value): cls.validate_int_in_range(value, 0, 18446744073709551615) -class ST_BrClear(XsdString): +class ST_BrClear(XsdString): @classmethod def validate(cls, value): cls.validate_string(value) - valid_values = ('none', 'left', 'right', 'all') + valid_values = ("none", "left", "right", "all") if value not in valid_values: - raise ValueError( - "must be one of %s, got '%s'" % (valid_values, value) - ) + raise ValueError("must be one of %s, got '%s'" % (valid_values, value)) class ST_BrType(XsdString): - @classmethod def validate(cls, value): cls.validate_string(value) - valid_values = ('page', 'column', 'textWrapping') + valid_values = ("page", "column", "textWrapping") if value not in valid_values: - raise ValueError( - "must be one of %s, got '%s'" % (valid_values, value) - ) + raise ValueError("must be one of %s, got '%s'" % (valid_values, value)) class ST_Coordinate(BaseIntType): - @classmethod def convert_from_xml(cls, str_value): - if 'i' in str_value or 'm' in str_value or 'p' in str_value: + if "i" in str_value or "m" in str_value or "p" in str_value: return ST_UniversalMeasure.convert_from_xml(str_value) return Emu(int(str_value)) @@ -222,7 +198,6 @@ def validate(cls, value): class ST_CoordinateUnqualified(XsdLong): - @classmethod def validate(cls, value): cls.validate_int_in_range(value, -27273042329600, 27273042316900) @@ -237,10 +212,9 @@ class ST_DrawingElementId(XsdUnsignedInt): class ST_HexColor(BaseStringType): - @classmethod def convert_from_xml(cls, str_value): - if str_value == 'auto': + if str_value == "auto": return ST_HexColorAuto.AUTO return RGBColor.from_string(str_value) @@ -250,7 +224,7 @@ def convert_to_xml(cls, value): Keep alpha hex numerals all uppercase just for consistency. """ # expecting 3-tuple of ints in range 0-255 - return '%02X%02X%02X' % value + return "%02X%02X%02X" % value @classmethod def validate(cls, value): @@ -266,7 +240,8 @@ class ST_HexColorAuto(XsdStringEnumeration): """ Value for `w:color/[@val="auto"] attribute setting """ - AUTO = 'auto' + + AUTO = "auto" _members = (AUTO,) @@ -275,9 +250,10 @@ class ST_HpsMeasure(XsdUnsignedLong): """ Half-point measure, e.g. 24.0 represents 12.0 points. """ + @classmethod def convert_from_xml(cls, str_value): - if 'm' in str_value or 'n' in str_value or 'p' in 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) @@ -292,26 +268,25 @@ class ST_Merge(XsdStringEnumeration): """ Valid values for attribute """ - CONTINUE = 'continue' - RESTART = 'restart' + + CONTINUE = "continue" + RESTART = "restart" _members = (CONTINUE, RESTART) class ST_OnOff(XsdBoolean): - @classmethod def convert_from_xml(cls, str_value): - if str_value not in ('1', '0', 'true', 'false', 'on', 'off'): + if str_value not in ("1", "0", "true", "false", "on", "off"): raise InvalidXmlError( "value must be one of '1', '0', 'true', 'false', 'on', or 'o" "ff', got '%s'" % str_value ) - return str_value in ('1', 'true', 'on') + return str_value in ("1", "true", "on") class ST_PositiveCoordinate(XsdLong): - @classmethod def convert_from_xml(cls, str_value): return Emu(int(str_value)) @@ -326,10 +301,9 @@ class ST_RelationshipId(XsdString): class ST_SignedTwipsMeasure(XsdInt): - @classmethod def convert_from_xml(cls, str_value): - if 'i' in str_value or 'm' in str_value or 'p' in str_value: + if "i" in str_value or "m" in str_value or "p" in str_value: return ST_UniversalMeasure.convert_from_xml(str_value) return Twips(int(str_value)) @@ -345,34 +319,27 @@ class ST_String(XsdString): class ST_TblLayoutType(XsdString): - @classmethod def validate(cls, value): cls.validate_string(value) - valid_values = ('fixed', 'autofit') + valid_values = ("fixed", "autofit") if value not in valid_values: - raise ValueError( - "must be one of %s, got '%s'" % (valid_values, value) - ) + raise ValueError("must be one of %s, got '%s'" % (valid_values, value)) class ST_TblWidth(XsdString): - @classmethod def validate(cls, value): cls.validate_string(value) - valid_values = ('auto', 'dxa', 'nil', 'pct') + valid_values = ("auto", "dxa", "nil", "pct") if value not in valid_values: - raise ValueError( - "must be one of %s, got '%s'" % (valid_values, value) - ) + raise ValueError("must be one of %s, got '%s'" % (valid_values, value)) class ST_TwipsMeasure(XsdUnsignedLong): - @classmethod def convert_from_xml(cls, str_value): - if 'i' in str_value or 'm' in str_value or 'p' in str_value: + if "i" in str_value or "m" in str_value or "p" in str_value: return ST_UniversalMeasure.convert_from_xml(str_value) return Twips(int(str_value)) @@ -384,14 +351,17 @@ def convert_to_xml(cls, value): class ST_UniversalMeasure(BaseSimpleType): - @classmethod def convert_from_xml(cls, str_value): float_part, units_part = str_value[:-2], str_value[-2:] quantity = float(float_part) multiplier = { - 'mm': 36000, 'cm': 360000, 'in': 914400, 'pt': 12700, - 'pc': 152400, 'pi': 152400 + "mm": 36000, + "cm": 360000, + "in": 914400, + "pt": 12700, + "pc": 152400, + "pi": 152400, }[units_part] emu_value = Emu(int(round(quantity * multiplier))) return emu_value @@ -401,9 +371,10 @@ class ST_VerticalAlignRun(XsdStringEnumeration): """ Valid values for `w:vertAlign/@val`. """ - BASELINE = 'baseline' - SUPERSCRIPT = 'superscript' - SUBSCRIPT = 'subscript' + + BASELINE = "baseline" + SUPERSCRIPT = "superscript" + SUBSCRIPT = "subscript" _members = (BASELINE, SUPERSCRIPT, SUBSCRIPT) @@ -412,9 +383,6 @@ class ST_FldCharType(XsdString): @classmethod def validate(cls, value): cls.validate_string(value) - valid_values = ('begin', 'separate', 'end') + valid_values = ("begin", "separate", "end") if value not in valid_values: - raise ValueError( - "must be one of %s, got '%s'" % (valid_values, value) - ) - + raise ValueError("must be one of %s, got '%s'" % (valid_values, value)) diff --git a/docx/oxml/text/run.py b/docx/oxml/text/run.py index 1e294d002..f0a81aa62 100644 --- a/docx/oxml/text/run.py +++ b/docx/oxml/text/run.py @@ -4,70 +4,103 @@ Custom element classes related to text runs (CT_R). """ -from ..ns import qn -from ..simpletypes import ST_BrClear, ST_BrType, ST_OnOff, ST_String, ST_FldCharType -from ..xmlchemy import (BaseOxmlElement, OptionalAttribute, RequiredAttribute, - ZeroOrMore, ZeroOrOne) from docx.enum.fields import WD_FIELDCODE +from docx.oxml.ns import qn +from docx.oxml.simpletypes import ( + ST_BrClear, + ST_BrType, + ST_FldCharType, + ST_OnOff, + ST_String, +) +from docx.oxml.xmlchemy import ( + BaseOxmlElement, + OptionalAttribute, + RequiredAttribute, + ZeroOrMore, + ZeroOrOne, +) class CT_SimpleField(BaseOxmlElement): """ `` element, indicating a simple field character. """ + instr = RequiredAttribute("w:instr", ST_String) - fldLock = OptionalAttribute('w:fldLock', ST_OnOff) - dirty = OptionalAttribute('w:dirty', ST_OnOff) + fldLock = OptionalAttribute("w:fldLock", ST_OnOff) + dirty = OptionalAttribute("w:dirty", ST_OnOff) def set_field(self, field_name, properties): - if getattr(WD_FIELDCODE, field_name): - self.instr = field_name + ' ' + properties + + # field_ = { + # WD_FIELDCODE.REF: "REF", + # WD_FIELDCODE.SEQ: "SEQ", + # WD_FIELDCODE.DATE: "DATE", + # WD_FIELDCODE.TOC: "TOC", + # }[field_name] + + field_ = field_name._member_name + + self.instr = field_ + " " + properties + class CT_FldChar(BaseOxmlElement): """ `` element, indicating a simple field character. """ + fldCharType = RequiredAttribute("w:fldCharType", ST_FldCharType) instrText = RequiredAttribute("w:instrText", ST_String) - fldLock = OptionalAttribute('w:fldLock', ST_OnOff) - dirty = OptionalAttribute('w:dirty', ST_OnOff) - - def set_field(self, codes): - self.instrText = code + fldLock = OptionalAttribute("w:fldLock", ST_OnOff) + dirty = OptionalAttribute("w:dirty", ST_OnOff) class CT_Br(BaseOxmlElement): """ ```` element, indicating a line, page, or column break in a run. """ - type = OptionalAttribute('w:type', ST_BrType) - clear = OptionalAttribute('w:clear', ST_BrClear) + + type = OptionalAttribute("w:type", ST_BrType) + clear = OptionalAttribute("w:clear", ST_BrClear) class CT_R(BaseOxmlElement): """ ```` element, containing the properties and text for a run. """ - rPr = ZeroOrOne('w:rPr') - t = ZeroOrMore('w:t') - br = ZeroOrMore('w:br') - cr = ZeroOrMore('w:cr') - tab = ZeroOrMore('w:tab') - drawing = ZeroOrMore('w:drawing') - fldsimple = ZeroOrMore('w:fldSimple') - fldChar = ZeroOrMore('w:fldChar') + + rPr = ZeroOrOne("w:rPr") + t = ZeroOrMore("w:t") + br = ZeroOrMore("w:br") + cr = ZeroOrMore("w:cr") + tab = ZeroOrMore("w:tab") + drawing = ZeroOrMore("w:drawing") + fldsimple = ZeroOrMore("w:fldSimple") + fldChar = ZeroOrMore("w:fldChar") + instrText = ZeroOrMore("w:instrText") def _insert_rPr(self, rPr): self.insert(0, rPr) return rPr + def add_fldChar(self, fldCharType): + fld = self._add_fldChar() + fld.fldCharType = fldCharType + return fld + + def add_instrText(self, instrText=None): + instr = self._add_instrText() + instr.text = instrText + return instr + def add_t(self, text): """ Return a newly added ```` element containing *text*. """ t = self._add_t(text=text) if len(text.strip()) < len(text): - t.set(qn('xml:space'), 'preserve') + t.set(qn("xml:space"), "preserve") return t def add_drawing(self, inline_or_anchor): @@ -114,15 +147,15 @@ def text(self): child elements like ```` translated to their Python equivalent. """ - text = '' + text = "" for child in self: - if child.tag == qn('w:t'): + if child.tag == qn("w:t"): t_text = child.text - text += t_text if t_text is not None else '' - elif child.tag == qn('w:tab'): - text += '\t' - elif child.tag in (qn('w:br'), qn('w:cr')): - text += '\n' + text += t_text if t_text is not None else "" + elif child.tag == qn("w:tab"): + text += "\t" + elif child.tag in (qn("w:br"), qn("w:cr")): + text += "\n" return text @text.setter @@ -146,6 +179,7 @@ class _RunContentAppender(object): appended. Likewise a newline or carriage return character ('\n', '\r') causes a ```` element to be appended. """ + def __init__(self, r): self._r = r self._bfr = [] @@ -177,17 +211,17 @@ def add_char(self, char): which must be called at the end of text to ensure any pending ```` element is written. """ - if char == '\t': + if char == "\t": self.flush() self._r.add_tab() - elif char in '\r\n': + elif char in "\r\n": self.flush() self._r.add_br() else: self._bfr.append(char) def flush(self): - text = ''.join(self._bfr) + text = "".join(self._bfr) if text: self._r.add_t(text) del self._bfr[:] diff --git a/docx/text/fields.py b/docx/text/fields.py new file mode 100644 index 000000000..f21ac7069 --- /dev/null +++ b/docx/text/fields.py @@ -0,0 +1,124 @@ +# encoding: utf-8 + +""" +Field-related proxy types. +""" + +from __future__ import absolute_import, division, print_function, unicode_literals + +from docx.enum.fields import WD_FIELDCODE +from docx.oxml.simpletypes import ST_FldCharType +from docx.shared import ElementProxy +from docx.text.run import Run +from docx.oxml.shared import qn + +class _SimpleField(object): + def __init__(self, field, field_run): + self._fieldText = field + self._field_run = field_run + + @property + def field_run(self): + return self._field_run + + @property + def field_text(self): + return self._fieldText.instr + + +class _Field(object): + def __init__(self, field, field_run, result_run): + self._fieldText, self._fieldResult = field + self._field_run = field_run + self._result_run = result_run + + @property + def field_run(self): + return self._field_run + + @property + def result_run(self): + return self._result_run + + @property + def field_text(self): + return self._fieldText.text + + @property + def result_text(self): + return self._fieldResult.text + + @result_text.setter + def result_text(self, text): + self._fieldResult.text = text + + +class ComplexField(ElementProxy): + """ + """ + + def __init__(self, field, parent): + super(ComplexField, self).__init__(field, parent) + self._fld_begin, self._fld_run, self._fld_seperate, self._fld_result, self._fld_end = ( + field + ) + self._parent = parent + + @classmethod + def new(cls, paragraph): + _fldChars = [] + for fldCharType in ["begin", "fld_run", "separate", "fld_result", "end"]: + run = paragraph.add_run() + if fldCharType in ["begin", "separate", "end"]: + run._r.add_fldChar(fldCharType) + _fldChars.append(run) + return cls(_fldChars, paragraph) + + # def _add_fldChar(self, fldChar): + + # @property + # def fields(self): + # return [Field_(r, self._parent) for r in self._element.xpath('//w:r[w:fldChar]')] + + def add_field(self, field_name, properties="", prelim_value=None): + # field_ = { + # WD_FIELDCODE.REF: "REF", + # WD_FIELDCODE.SEQ: "SEQ", + # WD_FIELDCODE.DATE: "DATE", + # WD_FIELDCODE.AUTHOR: "AUTHOR", + # }[field_name] + field_ = field_name._member_name + + fieldText = self._fld_run._r.add_instrText(field_) + fieldText.text += " " + properties.strip() + + fieldResult = self._fld_result._r.add_instrText() + if prelim_value is not None: + fieldResult.text = prelim_value + + return _Field((fieldText, fieldResult), self._fld_run, self._fld_result) + + # def insert_seperator(self): + # self.begin + # self._field_begin._add_fldChar() + + # run = self._parent.add_run() + # fldChar = run._element._add_fldChar() + # fldChar.fldCharType = "separate" + + # def end_field(self): + # run = self._parent.add_run() + # fldChar = run._element._add_fldChar() + # fldChar.fldCharType = "end" + # return fldChar + + # @property + # def begin(self): + # return Field_(*self._field_begin) + # #self._element.xpath('//w:fldChar[@w:fldCharType="begin"]')[0] + + # @property + # def end(self): + # return Field_(*self._field_end) + # return self._element.xpath('w:fldChar[@w:fldCharType="end"]')[0] + diff --git a/docx/text/paragraph.py b/docx/text/paragraph.py index 45d7d4919..7c77226de 100644 --- a/docx/text/paragraph.py +++ b/docx/text/paragraph.py @@ -11,7 +11,7 @@ from docx.shared import lazyproperty, Parented from docx.text.parfmt import ParagraphFormat from docx.text.run import Run - +from docx.text.fields import ComplexField, _SimpleField class Paragraph(Parented): """ @@ -39,28 +39,17 @@ def add_run(self, text=None, style=None): run.style = style return run - def add_simplefield(self, field_name, properties): + def add_simplefield(self, field_name, properties=""): r = self._p.add_r() run = Run(r, self) - fld = run.add_field(field_name, properties) - return fld - - def add_complexfield(self, fieldcode): - r = self._p.add_r() - fld_begin = r._add_fldChar() - fld_begin.fldCharType = "begin" - - r = self._p.add_r() - fld = r._add_fldChar() - fld.set_field(fieldcode) - r = self._p.add_r() - fld_sep = r._add_fldChar() - fld_sep.fldCharType = "separate" + fld = run.add_field(field_name=field_name, properties=properties) + return _SimpleField(fld, run) - r = self._p.add_r() - fld_end = r._add_fldChar() - fld_end.fldCharType = "end" - return fld + def add_field(self, field_name, properties="", prelim_value=None): + cmp_fld = ComplexField.new(self) + return cmp_fld.add_field( + field_name=field_name, properties=properties, prelim_value=prelim_value + ) @property def alignment(self): diff --git a/docx/text/run.py b/docx/text/run.py index d6c3c1369..014586ee3 100644 --- a/docx/text/run.py +++ b/docx/text/run.py @@ -21,6 +21,7 @@ class Run(Parented): not specified directly on the run and its effective value is taken from the style hierarchy. """ + def __init__(self, r, parent): super(Run, self).__init__(parent) self._r = self._element = self.element = r @@ -38,12 +39,12 @@ def add_break(self, break_type=WD_BREAK.LINE): *break_type* defaults to `WD_BREAK.LINE`. """ type_, clear = { - WD_BREAK.LINE: (None, None), - WD_BREAK.PAGE: ('page', None), - WD_BREAK.COLUMN: ('column', None), - WD_BREAK.LINE_CLEAR_LEFT: ('textWrapping', 'left'), - WD_BREAK.LINE_CLEAR_RIGHT: ('textWrapping', 'right'), - WD_BREAK.LINE_CLEAR_ALL: ('textWrapping', 'all'), + WD_BREAK.LINE: (None, None), + WD_BREAK.PAGE: ("page", None), + WD_BREAK.COLUMN: ("column", None), + WD_BREAK.LINE_CLEAR_LEFT: ("textWrapping", "left"), + WD_BREAK.LINE_CLEAR_RIGHT: ("textWrapping", "right"), + WD_BREAK.LINE_CLEAR_ALL: ("textWrapping", "all"), }[break_type] br = self._r.add_br() if type_ is not None: @@ -138,9 +139,7 @@ def style(self): @style.setter def style(self, style_or_name): - style_id = self.part.get_style_id( - style_or_name, WD_STYLE_TYPE.CHARACTER - ) + style_id = self.part.get_style_id(style_or_name, WD_STYLE_TYPE.CHARACTER) self._r.style = style_id @property @@ -191,6 +190,7 @@ class _Text(object): """ Proxy object wrapping ```` element. """ + def __init__(self, t_elm): super(_Text, self).__init__() self._t = t_elm From 893625dfe98757fa054cd48e2cbedbb55003b1ba Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Sun, 8 Sep 2019 21:12:59 +0200 Subject: [PATCH 50/52] spike: field code read access --- docx/text/fields.py | 116 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 102 insertions(+), 14 deletions(-) diff --git a/docx/text/fields.py b/docx/text/fields.py index f21ac7069..115ea5e3f 100644 --- a/docx/text/fields.py +++ b/docx/text/fields.py @@ -6,11 +6,50 @@ from __future__ import absolute_import, division, print_function, unicode_literals +from itertools import chain + +from docx.compat import Sequence from docx.enum.fields import WD_FIELDCODE +from docx.oxml.ns import qn +from docx.oxml.shared import qn from docx.oxml.simpletypes import ST_FldCharType -from docx.shared import ElementProxy +from docx.shared import ElementProxy, lazyproperty from docx.text.run import Run -from docx.oxml.shared import qn + + +class Fields(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 __getitem__(self, idx): + """Supports indexed and sliced access.""" + fields = self._finder.fields + if isinstance(idx, slice): + return [field for field in fields[idx]] + return fields[idx] + + def __iter__(self): + """Supports iteration.""" + return (field for field in self._finder.fields) + + def __len__(self): + return len(self._finder.fields) + + @lazyproperty + def _finder(self): + """_DocumentFieldFinder instance for this document.""" + return _DocumentFieldFinder(self._document_part) + class _SimpleField(object): def __init__(self, field, field_run): @@ -27,10 +66,8 @@ def field_text(self): class _Field(object): - def __init__(self, field, field_run, result_run): - self._fieldText, self._fieldResult = field - self._field_run = field_run - self._result_run = result_run + def __init__(self, field_runs): + self._field_run, self._result_run = field_runs @property def field_run(self): @@ -42,15 +79,15 @@ def result_run(self): @property def field_text(self): - return self._fieldText.text + str_lst = self._field_run.instrText_lst + if len(str_lst): + return str_lst[0].text @property def result_text(self): - return self._fieldResult.text - - @result_text.setter - def result_text(self, text): - self._fieldResult.text = text + str_lst = self._result_run.instrText_lst + if len(str_lst): + return str_lst[0].text class ComplexField(ElementProxy): @@ -95,8 +132,7 @@ def add_field(self, field_name, properties="", prelim_value=None): fieldResult = self._fld_result._r.add_instrText() if prelim_value is not None: fieldResult.text = prelim_value - - return _Field((fieldText, fieldResult), self._fld_run, self._fld_result) + return _Field((self._fld_run, self._fld_result)) # def insert_seperator(self): # self.begin @@ -122,3 +158,55 @@ def add_field(self, field_name, properties="", prelim_value=None): # return Field_(*self._field_end) # return self._element.xpath('w:fldChar[@w:fldCharType="end"]')[0] + +class _DocumentFieldFinder(object): + def __init__(self, document_part): + self._document_part = document_part + + @property + def fields(self): + """List of 'Simple' and 'Complex' fields in the document.""" + return list( + chain( + *( + _PartFieldFinder.iter_fields(part) + for part in self._document_part.iter_story_parts() + ) + ) + ) + + +class _PartFieldFinder(object): + def __init__(self, part): + self._part = part + + @classmethod + def iter_fields(cls, part): + """Generate each (bookmarkStart, bookmarkEnd) in *part*.""" + return cls(part)._iter_fields() + + def _iter_fields(self): + return chain(self._simplefields(), self._complexfields()) + + def _simplefields(self): + return ( + _SimpleField(fld, fld.fldsimple_lst[0]) + for fld in self._part.element.xpath("//w:r[w:fldSimple]") + ) + + def _complexfields(self): + for start_r, sep_r, _ in self._fieldchar_elements(): + field_run, result_run = start_r.getnext(), sep_r.getnext() + yield _Field((field_run, result_run)) + + def _fieldchar_elements(self): + order = deque(maxlen=3) + fld_chars = deque(maxlen=3) + run_objs = deque(maxlen=3) + for run in self._part.element.xpath("//w:r[w:fldChar]"): + fld = run.fldChar_lst[0] + order.append(fld.fldCharType) + run_objs.append(run) + fld_chars.append(fld) + if order == deque(["begin", "separate", "end"]): + yield run_objs From 5f2c797c64d48289312f5aa64d501ba5e89029d2 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Sat, 26 Oct 2019 11:33:29 +0200 Subject: [PATCH 51/52] spike: create document wide field finder --- docx/document.py | 8 +++++++- docx/{text => }/fields.py | 24 ++++++++++++++++-------- docx/parts/document.py | 6 ++++++ docx/parts/story.py | 5 +++++ docx/text/paragraph.py | 4 ++-- 5 files changed, 36 insertions(+), 11 deletions(-) rename docx/{text => }/fields.py (89%) diff --git a/docx/document.py b/docx/document.py index 28db658c3..6e4b62083 100644 --- a/docx/document.py +++ b/docx/document.py @@ -19,7 +19,7 @@ class Document(ElementProxy): a document. """ - __slots__ = ("__body", "_bookmarks", "_part") + __slots__ = ("__body", "_bookmarks", "_part", "_fields") def __init__(self, element, part): super(Document, self).__init__(element) @@ -116,6 +116,12 @@ def end_bookmark(self, bookmark): """Closes supplied bookmark at the end of the document.""" return self._body.end_bookmark(bookmark) + @lazyproperty + def fields(self): + """ + """ + return self._part.fields + @property def inline_shapes(self): """ diff --git a/docx/text/fields.py b/docx/fields.py similarity index 89% rename from docx/text/fields.py rename to docx/fields.py index 115ea5e3f..043275b36 100644 --- a/docx/text/fields.py +++ b/docx/fields.py @@ -15,6 +15,7 @@ from docx.oxml.simpletypes import ST_FldCharType from docx.shared import ElementProxy, lazyproperty from docx.text.run import Run +from collections import deque class Fields(Sequence): @@ -52,9 +53,8 @@ def _finder(self): class _SimpleField(object): - def __init__(self, field, field_run): - self._fieldText = field - self._field_run = field_run + def __init__(self, field): + self._field_run = field @property def field_run(self): @@ -62,7 +62,9 @@ def field_run(self): @property def field_text(self): - return self._fieldText.instr + str_lst = self._field_run.fldsimple_lst + if len(str_lst): + return str_lst[0].instr class _Field(object): @@ -80,15 +82,22 @@ def result_run(self): @property def field_text(self): str_lst = self._field_run.instrText_lst - if len(str_lst): + if str_lst: return str_lst[0].text @property def result_text(self): str_lst = self._result_run.instrText_lst - if len(str_lst): + if str_lst: return str_lst[0].text + @result_text.setter + def result_text(self, value): + + str_lst = self._result_run._r.instrText_lst + if str_lst: + str_lst[0].text = value + class ComplexField(ElementProxy): """ @@ -190,8 +199,7 @@ def _iter_fields(self): def _simplefields(self): return ( - _SimpleField(fld, fld.fldsimple_lst[0]) - for fld in self._part.element.xpath("//w:r[w:fldSimple]") + _SimpleField(fld) for fld in self._part.element.xpath("//w:r[w:fldSimple]") ) def _complexfields(self): diff --git a/docx/parts/document.py b/docx/parts/document.py index 796bae89b..f4213681c 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -17,6 +17,7 @@ from docx.parts.styles import StylesPart from docx.shape import InlineShapes from docx.shared import lazyproperty +from docx.fields import Fields class DocumentPart(BaseStoryPart): @@ -45,6 +46,11 @@ def bookmarks(self): """Singleton |Bookmarks| object for this docx package.""" return Bookmarks(self) + @lazyproperty + def fields(self): + """Singleton |Bookmarks| object for this docx package.""" + return Fields(self) + @property def core_properties(self): """ diff --git a/docx/parts/story.py b/docx/parts/story.py index 6a2692f6d..92cae5943 100644 --- a/docx/parts/story.py +++ b/docx/parts/story.py @@ -23,6 +23,11 @@ def bookmarks(self): """Global |Bookmarks| object for this docx package.""" return self._document_part.bookmarks + @lazyproperty + def fields(self): + """Global |Bookmarks| object for this docx package.""" + return self._document_part.fields + def get_or_add_image(self, image_descriptor): """Return (rId, image) pair for image identified by *image_descriptor*. diff --git a/docx/text/paragraph.py b/docx/text/paragraph.py index 7c77226de..b782d5a66 100644 --- a/docx/text/paragraph.py +++ b/docx/text/paragraph.py @@ -11,7 +11,7 @@ from docx.shared import lazyproperty, Parented from docx.text.parfmt import ParagraphFormat from docx.text.run import Run -from docx.text.fields import ComplexField, _SimpleField +from docx.fields import ComplexField, _SimpleField class Paragraph(Parented): """ @@ -43,7 +43,7 @@ def add_simplefield(self, field_name, properties=""): r = self._p.add_r() run = Run(r, self) fld = run.add_field(field_name=field_name, properties=properties) - return _SimpleField(fld, run) + return _SimpleField(run) def add_field(self, field_name, properties="", prelim_value=None): cmp_fld = ComplexField.new(self) From 06a96509ed182025ff0b14736ca7800cd20a2924 Mon Sep 17 00:00:00 2001 From: Benjamin Toornstra Date: Sat, 26 Oct 2019 11:37:08 +0200 Subject: [PATCH 52/52] spike: renam property names --- docx/fields.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docx/fields.py b/docx/fields.py index 043275b36..1e2cac62d 100644 --- a/docx/fields.py +++ b/docx/fields.py @@ -72,7 +72,7 @@ def __init__(self, field_runs): self._field_run, self._result_run = field_runs @property - def field_run(self): + def code_run(self): return self._field_run @property @@ -80,7 +80,7 @@ def result_run(self): return self._result_run @property - def field_text(self): + def code_text(self): str_lst = self._field_run.instrText_lst if str_lst: return str_lst[0].text @@ -93,7 +93,6 @@ def result_text(self): @result_text.setter def result_text(self, value): - str_lst = self._result_run._r.instrText_lst if str_lst: str_lst[0].text = value