From 703fce1e1769e3a63b1f517815547820ef699771 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 5 May 2014 21:21:19 -0700 Subject: [PATCH 001/809] docs: document feature analysis for Run.style --- docs/dev/analysis/features/char-style.rst | 138 ++++++++++++++++++++++ docs/dev/analysis/index.rst | 1 + docs/dev/analysis/schema/ct_p.rst | 4 +- 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 docs/dev/analysis/features/char-style.rst diff --git a/docs/dev/analysis/features/char-style.rst b/docs/dev/analysis/features/char-style.rst new file mode 100644 index 000000000..859446639 --- /dev/null +++ b/docs/dev/analysis/features/char-style.rst @@ -0,0 +1,138 @@ + +Character Style +=============== + +Word allows a set of run-level properties to be given a name. The set of +properties is called a *character style*. All the settings may be applied to +a run in a single action by setting the style of the run. + +Example: + + The normal font of a document is 10 point Times Roman. From time to time, + a Python class name appears in-line in the text. These short runs of + Python text are to appear in 9 point Courier. A character style named "Code" + is defined such that these words or phrases can be set to the distinctive + font and size in a single step. + + Later, it is decided that 10 point Menlo should be used for inline code + instead. The "Code" character style is updated to the new settings and all + instances of inline code in the document immediately appear in the new + font. + + +Protocol +-------- + +There are two call protocols related to character style: getting and setting +the character style of a run, and specifying a style when creating a run. + +Getting and setting the style of a run:: + + >>> run = p.add_run() + >>> run.style + None + >>> run.style = 'Emphasis' + >>> run.style + 'Emphasis' + >>> run.style = None + >>> run.style + None + +Assigning |None| to ``Run.style`` causes any applied character style to be +removed. A run without a character style inherits the character style of its +containing paragraph. + +Specifying the style of a run on creation:: + + >>> run = p.add_run() + >>> run.style + None + >>> run = p.add_run(style='Emphasis') + >>> run.style + 'Emphasis' + >>> run = p.add_run('text in this run', 'Strong') + >>> run.style + 'Strong' + + + +Specimen XML +------------ + +.. highlight:: xml + +A baseline regular run:: + + + + This is a regular paragraph. + + + +Adding *Emphasis* character style:: + + + + + + + This paragraph appears in Emphasis character style. + + + +A style that appears in the Word user interface (UI) with one or more spaces +in its name, such as "Subtle Emphasis", will generally have a style ID with +those spaces removed. In this example, "Subtle Emphasis" becomes +"SubtleEmphasis":: + + + + + + + a few words in Subtle Emphasis style + + + + + +Schema excerpt +-------------- + +.. highlight:: xml + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index 301cf2aba..7969330db 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,7 @@ Feature Analysis .. toctree:: :maxdepth: 1 + features/char-style features/breaks features/sections features/table diff --git a/docs/dev/analysis/schema/ct_p.rst b/docs/dev/analysis/schema/ct_p.rst index 80414388d..273318dea 100644 --- a/docs/dev/analysis/schema/ct_p.rst +++ b/docs/dev/analysis/schema/ct_p.rst @@ -1,6 +1,6 @@ -CT_P -==== +``CT_P`` +======== .. csv-table:: :header-rows: 0 From 520d90f03d8686c8e344704bee71b7572edd4ebf Mon Sep 17 00:00:00 2001 From: Victor Volle Date: Fri, 25 Apr 2014 14:21:35 +0200 Subject: [PATCH 002/809] acpt: add run-char-style.feature Acceptance tests for character style support, implemented as the read/write Run.style property. --- docx/text.py | 8 +++++ features/run-char-style.feature | 31 ++++++++++++++++++ features/steps/test_files/run-char-style.docx | Bin 0 -> 26645 bytes features/steps/text.py | 27 +++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 features/run-char-style.feature create mode 100644 features/steps/test_files/run-char-style.docx diff --git a/docx/text.py b/docx/text.py index 3d9c648ad..0b81303ef 100644 --- a/docx/text.py +++ b/docx/text.py @@ -300,6 +300,14 @@ def strike(self): """ return 'strike' + @property + def style(self): + """ + The string name of the character style applied to this run, or |None| + if it has no directly-applied character style. + """ + raise NotImplementedError + @property def text(self): """ diff --git a/features/run-char-style.feature b/features/run-char-style.feature new file mode 100644 index 000000000..c6af3b55f --- /dev/null +++ b/features/run-char-style.feature @@ -0,0 +1,31 @@ +Feature: Each run has a read/write style + In order to use the stylesheet capability built into Word + As an python-docx developer + I need the ability to get and set the character style of a run + + + @wip + Scenario Outline: Get the character style of a run + Given a run having style + Then the style of the run is + + Examples: Character styles + | char style | + | None | + | Emphasis | + | Strong | + + + @wip + Scenario Outline: Set the style of a run + Given a run having style + When I set the character style of the run to + Then the style of the run is + + Examples: Character style transitions + | char style | new char style | + | None | None | + | None | Emphasis | + | Emphasis | None | + | Emphasis | Emphasis | + | Emphasis | Strong | diff --git a/features/steps/test_files/run-char-style.docx b/features/steps/test_files/run-char-style.docx new file mode 100644 index 0000000000000000000000000000000000000000..5f57b96543505fd53cb50731d8b0a164da511043 GIT binary patch literal 26645 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 Tc=%^HWZ+91H1}6`&p!P>>SxvH literal 0 HcmV?d00001 diff --git a/features/steps/text.py b/features/steps/text.py index 95cb1162c..2b6ffbb6e 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -12,6 +12,8 @@ from docx.enum.text import WD_BREAK from docx.oxml.shared import qn +from .helpers import test_docx + # given =================================================== @@ -28,6 +30,15 @@ def given_a_run_having_bool_prop_set_on(context, bool_prop_name): context.run = run +@given('a run having style {char_style}') +def given_a_run_having_style_char_style(context, char_style): + run_idx = { + 'None': 0, 'Emphasis': 1, 'Strong': 2 + }[char_style] + document = Document(test_docx('run-char-style')) + context.run = document.paragraphs[0].runs[run_idx] + + # when ==================================================== @when('I add a column break') @@ -55,6 +66,14 @@ def when_assign_true_to_bool_run_prop(context, value_str, bool_prop_name): setattr(run, bool_prop_name, value) +@when('I set the character style of the run to {char_style}') +def when_I_set_the_character_style_of_the_run(context, char_style): + style_value = { + 'None': None, 'Emphasis': 'Emphasis', 'Strong': 'Strong' + }[char_style] + context.run.style = style_value + + # then ===================================================== @then('it is a column break') @@ -101,3 +120,11 @@ def then_run_inherits_bool_prop_value(context, boolean_prop_name): def then_run_appears_without_bool_prop(context, boolean_prop_name): run = context.run assert getattr(run, boolean_prop_name) is False + + +@then('the style of the run is {char_style}') +def then_the_style_of_the_run_is_char_style(context, char_style): + expected_value = { + 'None': None, 'Emphasis': 'Emphasis', 'Strong': 'Strong' + }[char_style] + assert context.run.style == expected_value From b0e3c80c285187e483e55fa21d025277a77ec630 Mon Sep 17 00:00:00 2001 From: Victor Volle Date: Wed, 30 Apr 2014 23:47:09 -0700 Subject: [PATCH 003/809] run: add Run.style getter --- docx/oxml/__init__.py | 1 + docx/oxml/text.py | 29 ++++++++++++++++++++++++++ docx/text.py | 6 +++--- features/run-char-style.feature | 1 - tests/oxml/unitdata/text.py | 4 ++++ tests/test_text.py | 37 +++++++++++++++++++++++++-------- 6 files changed, 65 insertions(+), 13 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 7f90cb134..3aa3fbdc5 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -72,6 +72,7 @@ register_custom_element_class('w:pStyle', CT_String) register_custom_element_class('w:r', CT_R) register_custom_element_class('w:rPr', CT_RPr) +register_custom_element_class('w:rStyle', CT_String) register_custom_element_class('w:rtl', CT_OnOff) register_custom_element_class('w:shadow', CT_OnOff) register_custom_element_class('w:smallCaps', CT_OnOff) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index fbcf6974f..bde255f75 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -267,6 +267,17 @@ def rPr(self): """ return self.find(qn('w:rPr')) + @property + def style(self): + """ + String contained in w:val attribute of grandchild, or + |None| if that element is not present. + """ + rPr = self.rPr + if rPr is None: + return None + return rPr.style + @property def t_lst(self): """ @@ -638,6 +649,13 @@ def remove_webHidden(self): for webHidden in webHidden_lst: self.remove(webHidden) + @property + def rStyle(self): + """ + ```` child element or None if not present. + """ + return self.find(qn('w:rStyle')) + @property def rtl(self): """ @@ -680,6 +698,17 @@ def strike(self): """ return self.find(qn('w:strike')) + @property + def style(self): + """ + String contained in child, or None if that element is not + present. + """ + rStyle = self.rStyle + if rStyle is None: + return None + return rStyle.val + @property def vanish(self): """ diff --git a/docx/text.py b/docx/text.py index 0b81303ef..df43da9ee 100644 --- a/docx/text.py +++ b/docx/text.py @@ -303,10 +303,10 @@ def strike(self): @property def style(self): """ - The string name of the character style applied to this run, or |None| - if it has no directly-applied character style. + Read/write. The string name of the character style applied to this + run, or |None| if it has no directly-applied character style. """ - raise NotImplementedError + return self._r.style @property def text(self): diff --git a/features/run-char-style.feature b/features/run-char-style.feature index c6af3b55f..99478ef2c 100644 --- a/features/run-char-style.feature +++ b/features/run-char-style.feature @@ -4,7 +4,6 @@ Feature: Each run has a read/write style I need the ability to get and set the character style of a run - @wip Scenario Outline: Get the character style of a run Given a run having style Then the style of the run is diff --git a/tests/oxml/unitdata/text.py b/tests/oxml/unitdata/text.py index dd4b9243f..7ed0287ab 100644 --- a/tests/oxml/unitdata/text.py +++ b/tests/oxml/unitdata/text.py @@ -162,5 +162,9 @@ def an_rPr(): return CT_RPrBuilder() +def an_rStyle(): + return CT_StringBuilder('w:rStyle') + + def an_rtl(): return CT_OnOffBuilder('w:rtl') diff --git a/tests/test_text.py b/tests/test_text.py index bb9f0ccc3..f258a6d72 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -20,7 +20,7 @@ a_b, a_bCs, a_br, a_caps, a_cs, a_dstrike, a_p, a_shadow, a_smallCaps, a_snapToGrid, a_specVanish, a_strike, a_t, a_vanish, a_webHidden, an_emboss, an_i, an_iCs, an_imprint, an_oMath, a_noProof, an_outline, - an_r, an_rPr, an_rtl + an_r, an_rPr, an_rStyle, an_rtl ) from .unitutil import class_mock, instance_mock @@ -135,6 +135,10 @@ def it_can_change_its_bool_prop_settings(self, bool_prop_set_fixture): setattr(run, prop_name, value) assert run._r.xml == expected_xml + def it_knows_its_character_style(self, style_get_fixture): + run, expected_style = style_get_fixture + assert run.style == expected_style + def it_can_add_text(self, add_text_fixture): run, text_str, expected_xml, Text_ = add_text_fixture _text = run.add_text(text_str) @@ -306,14 +310,18 @@ def bool_prop_set_fixture(self, request): expected_xml = an_r().with_nsdecls().with_child(rPr_bldr).xml() return run, bool_prop_name, value, expected_xml - @pytest.fixture - def run(self): - r = an_r().with_nsdecls().element - return Run(r) - - @pytest.fixture - def Text_(self, request): - return class_mock(request, 'docx.text.Text') + @pytest.fixture(params=['Foobar', None]) + def style_get_fixture(self, request): + style = request.param + r_bldr = an_r().with_nsdecls() + if style is not None: + r_bldr.with_child( + an_rPr().with_child( + an_rStyle().with_val(style)) + ) + r = r_bldr.element + run = Run(r) + return run, style @pytest.fixture def text_prop_fixture(self, Text_): @@ -324,3 +332,14 @@ def text_prop_fixture(self, Text_): ).element run = Run(r) return run, 'foobar' + + # fixture components --------------------------------------------- + + @pytest.fixture + def run(self): + r = an_r().with_nsdecls().element + return Run(r) + + @pytest.fixture + def Text_(self, request): + return class_mock(request, 'docx.text.Text') From 16afd9b8bf6c41f70508e3428076be4fee6fc71a Mon Sep 17 00:00:00 2001 From: Victor Volle Date: Sun, 4 May 2014 21:49:38 -0700 Subject: [PATCH 004/809] run: add Run.style setter --- docx/oxml/shared.py | 8 ++++++++ docx/oxml/text.py | 35 ++++++++++++++++++++++++++++++++- docx/text.py | 9 ++++++++- features/run-char-style.feature | 1 - tests/test_text.py | 34 +++++++++++++++++++++++++------- 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index 8177b79b2..7432b9847 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -333,6 +333,14 @@ def new_pStyle(cls, val): """ return OxmlElement('w:pStyle', attrs={qn('w:val'): val}) + @classmethod + def new_rStyle(cls, val): + """ + Return a new ```` element with ``val`` attribute set to + *val*. + """ + return OxmlElement('w:rStyle', attrs={qn('w:val'): val}) + @property def val(self): return self.get(qn('w:val')) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index bde255f75..8742fc941 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -270,7 +270,7 @@ def rPr(self): @property def style(self): """ - String contained in w:val attribute of grandchild, or + String contained in w:val attribute of grandchild, or |None| if that element is not present. """ rPr = self.rPr @@ -278,6 +278,15 @@ def style(self): return None return rPr.style + @style.setter + def style(self, style): + """ + Set the character style of this element to *style*. If *style* + is None, remove the style element. + """ + rPr = self.get_or_add_rPr() + rPr.style = style + @property def t_lst(self): """ @@ -609,6 +618,11 @@ def remove_outline(self): for outline in outline_lst: self.remove(outline) + def remove_rStyle(self): + rStyle = self.rStyle + if rStyle is not None: + self.remove(rStyle) + def remove_rtl(self): rtl_lst = self.findall(qn('w:rtl')) for rtl in rtl_lst: @@ -709,6 +723,20 @@ def style(self): return None return rStyle.val + @style.setter + def style(self, style): + """ + Set val attribute of child element to *style*, adding a + new element if necessary. If *style* is |None|, remove the + element if present. + """ + if style is None: + self.remove_rStyle() + elif self.rStyle is None: + self._add_rStyle(style) + else: + self.rStyle.val = style + @property def vanish(self): """ @@ -723,6 +751,11 @@ def webHidden(self): """ return self.find(qn('w:webHidden')) + def _add_rStyle(self, style): + rStyle = CT_String.new_rStyle(style) + self.insert(0, rStyle) + return rStyle + class CT_Text(OxmlBaseElement): """ diff --git a/docx/text.py b/docx/text.py index df43da9ee..addcb4c75 100644 --- a/docx/text.py +++ b/docx/text.py @@ -304,10 +304,17 @@ def strike(self): def style(self): """ Read/write. The string name of the character style applied to this - run, or |None| if it has no directly-applied character style. + run, or |None| if it has no directly-applied character style. Setting + this property to |None| causes any directly-applied character style + to be removed such that the run inherits character formatting from + its containing paragraph. """ return self._r.style + @style.setter + def style(self, char_style): + self._r.style = char_style + @property def text(self): """ diff --git a/features/run-char-style.feature b/features/run-char-style.feature index 99478ef2c..914025658 100644 --- a/features/run-char-style.feature +++ b/features/run-char-style.feature @@ -15,7 +15,6 @@ Feature: Each run has a read/write style | Strong | - @wip Scenario Outline: Set the style of a run Given a run having style When I set the character style of the run to diff --git a/tests/test_text.py b/tests/test_text.py index f258a6d72..1283595b6 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -139,6 +139,11 @@ def it_knows_its_character_style(self, style_get_fixture): run, expected_style = style_get_fixture assert run.style == expected_style + def it_can_change_its_character_style(self, style_set_fixture): + run, style, expected_xml = style_set_fixture + run.style = style + assert run._r.xml == expected_xml + def it_can_add_text(self, add_text_fixture): run, text_str, expected_xml, Text_ = add_text_fixture _text = run.add_text(text_str) @@ -313,16 +318,24 @@ def bool_prop_set_fixture(self, request): @pytest.fixture(params=['Foobar', None]) def style_get_fixture(self, request): style = request.param - r_bldr = an_r().with_nsdecls() - if style is not None: - r_bldr.with_child( - an_rPr().with_child( - an_rStyle().with_val(style)) - ) - r = r_bldr.element + r = self.r_bldr_with_style(style).element run = Run(r) return run, style + @pytest.fixture(params=[ + (None, None), + (None, 'Foobar'), + ('Foobar', None), + ('Foobar', 'Foobar'), + ('Foobar', 'Barfoo'), + ]) + def style_set_fixture(self, request): + before_style, after_style = request.param + r = self.r_bldr_with_style(before_style).element + run = Run(r) + expected_xml = self.r_bldr_with_style(after_style).xml() + return run, after_style, expected_xml + @pytest.fixture def text_prop_fixture(self, Text_): r = ( @@ -340,6 +353,13 @@ def run(self): r = an_r().with_nsdecls().element return Run(r) + def r_bldr_with_style(self, style): + rPr_bldr = an_rPr() + if style is not None: + rPr_bldr.with_child(an_rStyle().with_val(style)) + r_bldr = an_r().with_nsdecls().with_child(rPr_bldr) + return r_bldr + @pytest.fixture def Text_(self, request): return class_mock(request, 'docx.text.Text') From a80f0e35c98948b538c380ab1bb98beffda9ae3b Mon Sep 17 00:00:00 2001 From: Victor Volle Date: Mon, 5 May 2014 23:03:15 -0700 Subject: [PATCH 005/809] acpt: add par-add-run.feature Retroactively added an acceptance test for Paragraph.add_run(). Made some stylistic updates to naming in steps along the way. --- features/par-add-run.feature | 15 +++++++++++++++ features/steps/block.py | 2 +- features/steps/paragraph.py | 23 +++++++++++------------ features/steps/text.py | 17 ++++++++++++++++- 4 files changed, 43 insertions(+), 14 deletions(-) create mode 100644 features/par-add-run.feature diff --git a/features/par-add-run.feature b/features/par-add-run.feature new file mode 100644 index 000000000..55725bc89 --- /dev/null +++ b/features/par-add-run.feature @@ -0,0 +1,15 @@ +Feature: Add a run with optional text and style + In order to add distinctively formatted text to a paragraph + As a python-docx programmer + I want a way to add a styled run of text in a single step + + Scenario: Add a run specifying its text + Given a paragraph + When I add a run specifying its text + Then the run contains the text I specified + + @wip + Scenario: Add a run specifying its style + Given a paragraph + When I add a run specifying the character style Emphasis + Then the style of the run is Emphasis diff --git a/features/steps/block.py b/features/steps/block.py index 794e06c83..a98aa1cfc 100644 --- a/features/steps/block.py +++ b/features/steps/block.py @@ -23,7 +23,7 @@ def given_a_document_containing_a_table(context): @given('a paragraph') def given_a_paragraph(context): context.document = Document() - context.p = context.document.add_paragraph() + context.paragraph = context.document.add_paragraph() # when ==================================================== diff --git a/features/steps/paragraph.py b/features/steps/paragraph.py index d2e033879..ca1a98659 100644 --- a/features/steps/paragraph.py +++ b/features/steps/paragraph.py @@ -10,31 +10,32 @@ from helpers import saved_docx_path, test_text -test_style = 'Heading1' + +TEST_STYLE = 'Heading1' # when ==================================================== @when('I add a run to the paragraph') -def step_when_add_new_run_to_paragraph(context): +def when_add_new_run_to_paragraph(context): context.r = context.p.add_run() @when('I add text to the run') -def step_when_add_new_text_to_run(context): +def when_add_new_text_to_run(context): context.r.add_text(test_text) @when('I set the paragraph style') -def step_when_set_paragraph_style(context): - context.p.add_run().add_text(test_text) - context.p.style = test_style +def when_I_set_the_paragraph_style(context): + context.paragraph.add_run().add_text(test_text) + context.paragraph.style = TEST_STYLE # then ===================================================== @then('the document contains the text I added') -def step_then_document_contains_text_I_added(context): +def then_document_contains_text_I_added(context): document = Document(saved_docx_path) paragraphs = document.paragraphs p = paragraphs[-1] @@ -43,8 +44,6 @@ def step_then_document_contains_text_I_added(context): @then('the paragraph has the style I set') -def step_then_paragraph_has_the_style_I_set(context): - document = Document(saved_docx_path) - paragraphs = document.paragraphs - p = paragraphs[-1] - assert p.style == test_style +def then_the_paragraph_has_the_style_I_set(context): + paragraph = Document(saved_docx_path).paragraphs[-1] + assert paragraph.style == TEST_STYLE diff --git a/features/steps/text.py b/features/steps/text.py index 2b6ffbb6e..09e110b91 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -12,7 +12,7 @@ from docx.enum.text import WD_BREAK from docx.oxml.shared import qn -from .helpers import test_docx +from .helpers import test_docx, test_text # given =================================================== @@ -59,6 +59,16 @@ def when_add_page_break(context): run.add_break(WD_BREAK.PAGE) +@when('I add a run specifying its text') +def when_I_add_a_run_specifying_its_text(context): + context.run = context.paragraph.add_run(test_text) + + +@when('I add a run specifying the character style Emphasis') +def when_I_add_a_run_specifying_the_character_style_Emphasis(context): + context.run = context.paragraph.add_run(test_text, 'Emphasis') + + @when('I assign {value_str} to its {bool_prop_name} property') def when_assign_true_to_bool_run_prop(context, value_str, bool_prop_name): value = {'True': True, 'False': False, 'None': None}[value_str] @@ -122,6 +132,11 @@ def then_run_appears_without_bool_prop(context, boolean_prop_name): assert getattr(run, boolean_prop_name) is False +@then('the run contains the text I specified') +def then_the_run_contains_the_text_I_specified(context): + assert context.run.text == test_text + + @then('the style of the run is {char_style}') def then_the_style_of_the_run_is_char_style(context, char_style): expected_value = { From 10e77f0a8d0f283602e6a1a848ee4d152f8b29e5 Mon Sep 17 00:00:00 2001 From: Victor Volle Date: Mon, 5 May 2014 23:23:10 -0700 Subject: [PATCH 006/809] run: add style param to Paragraph.add_run() --- docx/text.py | 7 +++++-- features/par-add-run.feature | 1 - tests/test_text.py | 20 ++++++++++++++------ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/docx/text.py b/docx/text.py index addcb4c75..4f883ab86 100644 --- a/docx/text.py +++ b/docx/text.py @@ -58,14 +58,17 @@ def __init__(self, p): super(Paragraph, self).__init__() self._p = p - def add_run(self, text=None): + def add_run(self, text=None, style=None): """ - Append a run to this paragraph. + Append a run to this paragraph containing *text* and having character + style identified by style ID *style*. """ r = self._p.add_r() run = Run(r) if text: run.add_text(text) + if style: + run.style = style return run @property diff --git a/features/par-add-run.feature b/features/par-add-run.feature index 55725bc89..5be830c82 100644 --- a/features/par-add-run.feature +++ b/features/par-add-run.feature @@ -8,7 +8,6 @@ Feature: Add a run with optional text and style When I add a run specifying its text Then the run contains the text I specified - @wip Scenario: Add a run specifying its style Given a paragraph When I add a run specifying the character style Emphasis diff --git a/tests/test_text.py b/tests/test_text.py index 1283595b6..55a7fc61f 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -34,8 +34,8 @@ def it_has_a_sequence_of_the_runs_it_contains(self, runs_fixture): assert runs == [run_, run_2_] def it_can_add_a_run_to_itself(self, add_run_fixture): - paragraph, text, expected_xml = add_run_fixture - run = paragraph.add_run(text) + paragraph, text, style, expected_xml = add_run_fixture + run = paragraph.add_run(text, style) assert paragraph._p.xml == expected_xml assert isinstance(run, Run) assert run._r is paragraph._p.r_lst[0] @@ -66,21 +66,29 @@ def it_knows_the_text_it_contains(self, text_prop_fixture): # fixtures ------------------------------------------------------- - @pytest.fixture(params=[None, '', 'foobar']) + @pytest.fixture(params=[ + (None, None), (None, 'Strong'), ('foobar', None), ('foobar', 'Strong') + ]) def add_run_fixture(self, request, paragraph): - text = request.param + text, style = request.param r_bldr = an_r() + if style: + r_bldr.with_child( + an_rPr().with_child(an_rStyle().with_val(style)) + ) if text: r_bldr.with_child(a_t().with_text(text)) expected_xml = a_p().with_nsdecls().with_child(r_bldr).xml() - return paragraph, text, expected_xml + return paragraph, text, style, expected_xml + + # fixture components --------------------------------------------- @pytest.fixture def p_(self, request, r_, r_2_): return instance_mock(request, CT_P, r_lst=(r_, r_2_)) @pytest.fixture - def paragraph(self, request): + def paragraph(self): p = a_p().with_nsdecls().element return Paragraph(p) From 5a2da7f52246674d761ecea7b78d7d0e004b9b9b Mon Sep 17 00:00:00 2001 From: Victor Volle Date: Tue, 6 May 2014 22:52:24 -0700 Subject: [PATCH 007/809] docs: add user documentation for Run.style --- docs/user/quickstart.rst | 38 +++++++++++++++++++++++++++++++++++--- docs/user/shapes.rst | 2 +- docx/text.py | 10 +++++----- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/docs/user/quickstart.rst b/docs/user/quickstart.rst index e81159296..39ef358a7 100644 --- a/docs/user/quickstart.rst +++ b/docs/user/quickstart.rst @@ -221,9 +221,12 @@ the one above:: p = document.add_paragraph('Lorem ipsum dolor sit amet.') p.style = 'ListBullet' -Again, the style name is formed by removing the spaces in the name as it -appears in the Word UI. So the style 'List Number 3' would be specified as -``'ListNumber3'``. +The style is specified using its style ID, 'ListBullet' in this example. +Generally, the style ID is formed by removing the spaces in the style name as +it appears in the Word user interface (UI). So the style 'List Number 3' +would be specified as ``'ListNumber3'``. However, note that if you are using +a localized version of Word, the style ID may be derived from the English +style name and may not correspond so neatly to its style name in the Word UI. Applying bold and italic @@ -285,3 +288,32 @@ make your code simpler if you're building the paragraph up from runs anyway:: p.add_run('Lorem ipsum ') p.add_run('dolor').bold = True p.add_run(' sit amet.') + + +Applying a character style +-------------------------- + +In addition to paragraph styles, which specify a group of paragraph-level +settings, Word has *character styles* which specify a group of run-level +settings. In general you can think of a character style as specifying a font, +including its typeface, size, color, bold, italic, etc. + +Like paragraph styles, a character style must already be defined in the document you open with the ``Document()`` call (*see* :doc:`styles`). + +A character style can be specified when adding a new run:: + + p = document.add_paragraph('Normal text, ') + p.add_run('text with emphasis.', 'Emphasis') + +You can also apply a style to a run after it is created. This code produces +the same result as the lines above:: + + p = document.add_paragraph('Normal text, ') + r = p.add_run('text with emphasis.') + r.style = 'Emphasis' + +As with a paragraph style, the style ID is formed by removing the spaces in +the name as it appears in the Word UI. So the style 'Subtle Emphasis' would +be specified as ``'SubtleEmphasis'``. Note that if you are using +a localized version of Word, the style ID may be derived from the English +style name and may not correspond to its style name in the Word UI. diff --git a/docs/user/shapes.rst b/docs/user/shapes.rst index 6cb402545..ec5d22797 100644 --- a/docs/user/shapes.rst +++ b/docs/user/shapes.rst @@ -6,7 +6,7 @@ Conceptually, Word documents have two *layers*, a *text layer* and a *drawing layer*. In the text layer, text objects are flowed from left to right and from top to bottom, starting a new page when the prior one is filled. In the drawing layer, drawing objects, called *shapes*, are placed at arbitrary positions. -and are sometimes referred to as *floating* shapes. +These are sometimes referred to as *floating* shapes. A picture is a shape that can appear in either the text or drawing layer. When it appears in the text layer it is called an *inline shape*, or more diff --git a/docx/text.py b/docx/text.py index 4f883ab86..06ddd9735 100644 --- a/docx/text.py +++ b/docx/text.py @@ -306,11 +306,11 @@ def strike(self): @property def style(self): """ - Read/write. The string name of the character style applied to this - run, or |None| if it has no directly-applied character style. Setting - this property to |None| causes any directly-applied character style - to be removed such that the run inherits character formatting from - its containing paragraph. + Read/write. The string style ID of the character style applied to + this run, or |None| if it has no directly-applied character style. + Setting this property to |None| causes any directly-applied character + style to be removed such that the run inherits character formatting + from its containing paragraph. """ return self._r.style From b28988458357b939ab2b0e34bd3d6037c5d2935c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 6 May 2014 23:45:13 -0700 Subject: [PATCH 008/809] release: v0.5.2 #17 feature: character style --- HISTORY.rst | 6 ++++++ docx/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 9661657b6..f18734895 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,12 @@ Release History --------------- +0.5.2 (2014-05-06) +++++++++++++++++++ + +- Add #17 feature: character style + + 0.5.1 (2014-04-02) ++++++++++++++++++ diff --git a/docx/__init__.py b/docx/__init__.py index be95f54b8..0d67a12fc 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.5.1' +__version__ = '0.5.2' # register custom Part classes with opc package reader From 599b9c933d8e1d269e2f4cc7544fd7b45a75662d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 8 May 2014 01:27:51 -0700 Subject: [PATCH 009/809] docs: document Run.underline feature analysis --- docs/dev/analysis/features/char-style.rst | 6 +- docs/dev/analysis/features/underline.rst | 152 ++++++++++++++++++++++ docs/dev/analysis/index.rst | 1 + 3 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 docs/dev/analysis/features/underline.rst diff --git a/docs/dev/analysis/features/char-style.rst b/docs/dev/analysis/features/char-style.rst index 859446639..9b62b9983 100644 --- a/docs/dev/analysis/features/char-style.rst +++ b/docs/dev/analysis/features/char-style.rst @@ -122,9 +122,9 @@ Schema excerpt - - - + + + diff --git a/docs/dev/analysis/features/underline.rst b/docs/dev/analysis/features/underline.rst new file mode 100644 index 000000000..0e206c1b0 --- /dev/null +++ b/docs/dev/analysis/features/underline.rst @@ -0,0 +1,152 @@ + +Run underline +============= + +Text in a Word document can be underlined in a variety of styles. + + +Protocol +-------- + +The call protocol for underline is overloaded such that it works like +``.bold`` and ``.italic`` for single underline, but also allows an enumerated +value to be assigned to specify more sophisticated underlining such as +dashed, wavy, and double-underline:: + + >>> run = paragraph.add_run() + >>> run.underline + None + >>> run.underline = True + >>> run.underline + True + >>> run.underline = WD_UNDERLINE.SINGLE + >>> run.underline + True + >>> run.underline = WD_UNDERLINE.DOUBLE + >>> str(run.underline) + DOUBLE (3) + >>> run.underline = False + >>> run.underline + False + >>> run.underline = WD_UNDERLINE.NONE + >>> run.underline + False + >>> run.underline = None + >>> run.underline + None + + +Enumerations +------------ + +* `WdUnderline Enumeration on MSDN`_ + +.. _WdUnderline Enumeration on MSDN: + http://msdn.microsoft.com/en-us/library/office/ff822388(v=office.15).aspx + + +Specimen XML +------------ + +.. highlight:: xml + +Baseline run:: + + + underlining determined by inheritance + + +Single underline:: + + + + + + single underlined + + +Double underline:: + + + + + + single underlined + + +Directly-applied no-underline, overrides inherited value:: + + + + + + not underlined + + + +Schema excerpt +-------------- + +.. highlight:: xml + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index 7969330db..2b1225c6d 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,7 @@ Feature Analysis .. toctree:: :maxdepth: 1 + features/underline features/char-style features/breaks features/sections From 7b40ab329fe388ffa92727d75a846e79d5f4c8ea Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 9 May 2014 01:38:30 -0700 Subject: [PATCH 010/809] acpt: add run-enum-props.feature Add scenario outline to test Run.underline getter at API level. --- docx/enum/text.py | 25 ++++++++++++++++++ features/run-enum-props.feature | 17 ++++++++++++ .../test_files/run-enumerated-props.docx | Bin 0 -> 14645 bytes features/steps/text.py | 20 +++++++++++++- 4 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 features/run-enum-props.feature create mode 100644 features/steps/test_files/run-enumerated-props.docx diff --git a/docx/enum/text.py b/docx/enum/text.py index 9260e6fa0..8535fbcc1 100644 --- a/docx/enum/text.py +++ b/docx/enum/text.py @@ -25,3 +25,28 @@ class WD_BREAK_TYPE(object): TEXT_WRAPPING = 11 WD_BREAK = WD_BREAK_TYPE + + +class WD_UNDERLINE(object): + """ + Corresponds to WdUnderline enumeration + http://msdn.microsoft.com/en-us/library/office/ff822388.aspx + """ + NONE = 0 + SINGLE = 1 + WORDS = 2 + DOUBLE = 3 + DOTTED = 4 + THICK = 6 + DASH = 7 + DOT_DASH = 9 + DOT_DOT_DASH = 10 + WAVY = 11 + DOTTED_HEAVY = 20 + DASH_HEAVY = 23 + DOT_DASH_HEAVY = 25 + DOT_DOT_DASH_HEAVY = 26 + WAVY_HEAVY = 27 + DASH_LONG = 39 + WAVY_DOUBLE = 43 + DASH_LONG_HEAVY = 55 diff --git a/features/run-enum-props.feature b/features/run-enum-props.feature new file mode 100644 index 000000000..c0b139d31 --- /dev/null +++ b/features/run-enum-props.feature @@ -0,0 +1,17 @@ +Feature: Query or apply enumerated run property + In order to query or change an enumerated font property of a word or phrase + As a python-docx developer + I need a way to query and set the enumerated properties on a run + + + @wip + Scenario Outline: Get underline value of a run + Given a run having underline + Then the run underline property value is + + Examples: underline property values + | underline-type | underline-value | + | inherited | None | + | no | False | + | single | True | + | double | WD_UNDERLINE.DOUBLE | diff --git a/features/steps/test_files/run-enumerated-props.docx b/features/steps/test_files/run-enumerated-props.docx new file mode 100644 index 0000000000000000000000000000000000000000..12fdda4312b046bf301179ab15b0732189737d45 GIT binary patch literal 14645 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 Date: Fri, 9 May 2014 03:02:45 -0700 Subject: [PATCH 011/809] run: add Run.underline getter --- docx/oxml/__init__.py | 5 ++- docx/oxml/text.py | 64 +++++++++++++++++++++++++++++++++ docx/text.py | 15 ++++++++ features/run-enum-props.feature | 1 - tests/oxml/unitdata/text.py | 12 +++++++ tests/test_text.py | 27 ++++++++++++-- 6 files changed, 120 insertions(+), 4 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 3aa3fbdc5..820eeec1d 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -53,7 +53,9 @@ register_custom_element_class('w:tc', CT_Tc) register_custom_element_class('w:tr', CT_Row) -from docx.oxml.text import CT_Br, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text +from docx.oxml.text import ( + CT_Br, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text, CT_Underline +) register_custom_element_class('w:b', CT_OnOff) register_custom_element_class('w:bCs', CT_OnOff) register_custom_element_class('w:br', CT_Br) @@ -80,5 +82,6 @@ register_custom_element_class('w:specVanish', CT_OnOff) register_custom_element_class('w:strike', CT_OnOff) register_custom_element_class('w:t', CT_Text) +register_custom_element_class('w:u', CT_Underline) register_custom_element_class('w:vanish', CT_OnOff) register_custom_element_class('w:webHidden', CT_OnOff) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 8742fc941..f1812e457 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -5,6 +5,7 @@ (CT_R). """ +from docx.enum.text import WD_UNDERLINE from docx.oxml.parts.numbering import CT_NumPr from docx.oxml.shared import ( CT_String, nsdecls, OxmlBaseElement, OxmlElement, oxml_fromstring, qn @@ -294,6 +295,17 @@ def t_lst(self): """ return self.findall(qn('w:t')) + @property + def underline(self): + """ + String contained in w:val attribute of grandchild, or |None| if + that element is not present. + """ + rPr = self.rPr + if rPr is None: + return None + return rPr.underline + def _add_rPr(self): """ Return a newly added rPr child element. Assumes one is not present. @@ -737,6 +749,24 @@ def style(self, style): else: self.rStyle.val = style + @property + def u(self): + """ + First ```` child element or |None| if none are present. + """ + return self.find(qn('w:u')) + + @property + def underline(self): + """ + Underline type specified in child, or None if that element is + not present. + """ + u = self.u + if u is None: + return None + return u.val + @property def vanish(self): """ @@ -769,3 +799,37 @@ def new(cls, text): t = OxmlElement('w:t') t.text = text return t + + +class CT_Underline(OxmlBaseElement): + """ + ```` element, specifying the underlining style for a run. + """ + @property + def val(self): + """ + The underline type corresponding to the ``w:val`` attribute value. + """ + underline_type_map = { + None: None, + 'none': False, + 'single': True, + 'words': WD_UNDERLINE.WORDS, + 'double': WD_UNDERLINE.DOUBLE, + 'dotted': WD_UNDERLINE.DOTTED, + 'thick': WD_UNDERLINE.THICK, + 'dash': WD_UNDERLINE.DASH, + 'dotDash': WD_UNDERLINE.DOT_DASH, + 'dotDotDash': WD_UNDERLINE.DOT_DOT_DASH, + 'wave': WD_UNDERLINE.WAVY, + 'dottedHeavy': WD_UNDERLINE.DOTTED_HEAVY, + 'dashedHeavy': WD_UNDERLINE.DASH_HEAVY, + 'dashDotHeavy': WD_UNDERLINE.DOT_DASH_HEAVY, + 'dashDotDotHeavy': WD_UNDERLINE.DOT_DOT_DASH_HEAVY, + 'wavyHeavy': WD_UNDERLINE.WAVY_HEAVY, + 'dashLong': WD_UNDERLINE.DASH_LONG, + 'wavyDouble': WD_UNDERLINE.WAVY_DOUBLE, + 'dashLongHeavy': WD_UNDERLINE.DASH_LONG_HEAVY, + } + val = self.get(qn('w:val')) + return underline_type_map[val] diff --git a/docx/text.py b/docx/text.py index 06ddd9735..cf2c87705 100644 --- a/docx/text.py +++ b/docx/text.py @@ -329,6 +329,21 @@ def text(self): text += t.text return text + @property + def underline(self): + """ + The underline style for this |Run|, one of |None|, |True|, |False|, + or a value from ``pptx.enum.text.WD_UNDERLINE``. A value of |None| + indicates the run has no directly-applied underline value and so will + inherit the underline value of its containing paragraph. Assigning + |None| to this property removes any directly-applied underline value. + A value of |False| indicates a directly-applied setting of no + underline, overriding any inherited value. A value of |True| + indicates single underline. The values from ``WD_UNDERLINE`` are used + to specify other outline styles such as double, wavy, and dotted. + """ + return self._r.underline + @boolproperty def web_hidden(self): """ diff --git a/features/run-enum-props.feature b/features/run-enum-props.feature index c0b139d31..5d287f497 100644 --- a/features/run-enum-props.feature +++ b/features/run-enum-props.feature @@ -4,7 +4,6 @@ Feature: Query or apply enumerated run property I need a way to query and set the enumerated properties on a run - @wip Scenario Outline: Get underline value of a run Given a run having underline Then the run underline property value is diff --git a/tests/oxml/unitdata/text.py b/tests/oxml/unitdata/text.py index 7ed0287ab..9d3d9eb60 100644 --- a/tests/oxml/unitdata/text.py +++ b/tests/oxml/unitdata/text.py @@ -54,6 +54,14 @@ def with_space(self, value): return self +class CT_Underline(BaseBuilder): + __tag__ = 'w:u' + __nspfxs__ = ('w',) + __attrs__ = ( + 'w:val', 'w:color', 'w:themeColor', 'w:themeTint', 'w:themeShade' + ) + + def a_b(): return CT_OnOffBuilder('w:b') @@ -130,6 +138,10 @@ def a_t(): return CT_TextBuilder() +def a_u(): + return CT_Underline() + + def an_emboss(): return CT_OnOffBuilder('w:emboss') diff --git a/tests/test_text.py b/tests/test_text.py index 55a7fc61f..97773edb1 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -8,7 +8,7 @@ absolute_import, division, print_function, unicode_literals ) -from docx.enum.text import WD_BREAK +from docx.enum.text import WD_BREAK, WD_UNDERLINE from docx.oxml.text import CT_P, CT_R from docx.text import Paragraph, Run @@ -18,7 +18,7 @@ from .oxml.unitdata.text import ( a_b, a_bCs, a_br, a_caps, a_cs, a_dstrike, a_p, a_shadow, a_smallCaps, - a_snapToGrid, a_specVanish, a_strike, a_t, a_vanish, a_webHidden, + a_snapToGrid, a_specVanish, a_strike, a_t, a_u, a_vanish, a_webHidden, an_emboss, an_i, an_iCs, an_imprint, an_oMath, a_noProof, an_outline, an_r, an_rPr, an_rStyle, an_rtl ) @@ -152,6 +152,10 @@ def it_can_change_its_character_style(self, style_set_fixture): run.style = style assert run._r.xml == expected_xml + def it_knows_its_underline_type(self, underline_get_fixture): + run, expected_value = underline_get_fixture + assert run.underline == expected_value + def it_can_add_text(self, add_text_fixture): run, text_str, expected_xml, Text_ = add_text_fixture _text = run.add_text(text_str) @@ -354,6 +358,18 @@ def text_prop_fixture(self, Text_): run = Run(r) return run, 'foobar' + @pytest.fixture(params=[ + (None, None), + ('single', True), + ('none', False), + ('double', WD_UNDERLINE.DOUBLE), + ]) + def underline_get_fixture(self, request): + underline_type, expected_prop_value = request.param + r = self.r_bldr_with_underline(underline_type).element + run = Run(r) + return run, expected_prop_value + # fixture components --------------------------------------------- @pytest.fixture @@ -368,6 +384,13 @@ def r_bldr_with_style(self, style): r_bldr = an_r().with_nsdecls().with_child(rPr_bldr) return r_bldr + def r_bldr_with_underline(self, underline_type): + rPr_bldr = an_rPr() + if underline_type is not None: + rPr_bldr.with_child(a_u().with_val(underline_type)) + r_bldr = an_r().with_nsdecls().with_child(rPr_bldr) + return r_bldr + @pytest.fixture def Text_(self, request): return class_mock(request, 'docx.text.Text') From 4948b9097be8f3eade63232f13ceb06cc9dd4a2d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 10 May 2014 00:52:38 -0700 Subject: [PATCH 012/809] acpt: add scenario for Run.underline setter --- features/run-enum-props.feature | 20 ++++++++++++++++++++ features/steps/text.py | 10 ++++++++++ 2 files changed, 30 insertions(+) diff --git a/features/run-enum-props.feature b/features/run-enum-props.feature index 5d287f497..7124032b0 100644 --- a/features/run-enum-props.feature +++ b/features/run-enum-props.feature @@ -14,3 +14,23 @@ Feature: Query or apply enumerated run property | no | False | | single | True | | double | WD_UNDERLINE.DOUBLE | + + + @wip + Scenario Outline: Change underline setting for a run + Given a run having underline + When I set the run underline to + Then the run underline property value is + + Examples: underline property values + | underline-type | new-underline-value | expected-underline-value | + | inherited | True | True | + | inherited | False | False | + | inherited | None | None | + | inherited | WD_UNDERLINE.SINGLE | True | + | inherited | WD_UNDERLINE.DOUBLE | WD_UNDERLINE.DOUBLE | + | single | None | None | + | single | True | True | + | single | False | False | + | single | WD_UNDERLINE.SINGLE | True | + | single | WD_UNDERLINE.DOUBLE | WD_UNDERLINE.DOUBLE | diff --git a/features/steps/text.py b/features/steps/text.py index 11b157962..ead0c1a15 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -93,6 +93,16 @@ def when_I_set_the_character_style_of_the_run(context, char_style): context.run.style = style_value +@when('I set the run underline to {underline_value}') +def when_I_set_the_run_underline_to_value(context, underline_value): + new_value = { + 'True': True, 'False': False, 'None': None, + 'WD_UNDERLINE.SINGLE': WD_UNDERLINE.SINGLE, + 'WD_UNDERLINE.DOUBLE': WD_UNDERLINE.DOUBLE, + }[underline_value] + context.run.underline = new_value + + # then ===================================================== @then('it is a column break') From b84d64fe2aa0eeeaff1797af5bec72cc9e545cbe Mon Sep 17 00:00:00 2001 From: Justin Date: Sat, 10 May 2014 02:31:43 -0700 Subject: [PATCH 013/809] run: add Run.underline setter --- docx/oxml/text.py | 50 +++++++++++++++++++++++++++++++++ docx/text.py | 4 +++ features/run-enum-props.feature | 1 - tests/test_text.py | 24 ++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index f1812e457..37b1edefe 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -306,6 +306,11 @@ def underline(self): return None return rPr.underline + @underline.setter + def underline(self, value): + rPr = self.get_or_add_rPr() + rPr.underline = value + def _add_rPr(self): """ Return a newly added rPr child element. Assumes one is not present. @@ -665,6 +670,11 @@ def remove_strike(self): for strike in strike_lst: self.remove(strike) + def remove_u(self): + u_lst = self.findall(qn('w:u')) + for u in u_lst: + self.remove(u) + def remove_vanish(self): vanish_lst = self.findall(qn('w:vanish')) for vanish in vanish_lst: @@ -767,6 +777,13 @@ def underline(self): return None return u.val + @underline.setter + def underline(self, value): + self.remove_u() + if value is not None: + u = self._add_u() + u.val = value + @property def vanish(self): """ @@ -786,6 +803,14 @@ def _add_rStyle(self, style): self.insert(0, rStyle) return rStyle + def _add_u(self): + """ + Return a newly added child element. + """ + u = OxmlElement('w:u') + self.insert(0, u) + return u + class CT_Text(OxmlBaseElement): """ @@ -833,3 +858,28 @@ def val(self): } val = self.get(qn('w:val')) return underline_type_map[val] + + @val.setter + def val(self, value): + underline_vals = { + True: 'single', + False: 'none', + WD_UNDERLINE.WORDS: 'words', + WD_UNDERLINE.DOUBLE: 'double', + WD_UNDERLINE.DOTTED: 'dotted', + WD_UNDERLINE.THICK: 'thick', + WD_UNDERLINE.DASH: 'dash', + WD_UNDERLINE.DOT_DASH: 'dotDash', + WD_UNDERLINE.DOT_DOT_DASH: 'dotDotDash', + WD_UNDERLINE.WAVY: 'wave', + WD_UNDERLINE.DOTTED_HEAVY: 'dottedHeavy', + WD_UNDERLINE.DASH_HEAVY: 'dashedHeavy', + WD_UNDERLINE.DOT_DASH_HEAVY: 'dashDotHeavy', + WD_UNDERLINE.DOT_DOT_DASH_HEAVY: 'dashDotDotHeavy', + WD_UNDERLINE.WAVY_HEAVY: 'wavyHeavy', + WD_UNDERLINE.DASH_LONG: 'dashLong', + WD_UNDERLINE.WAVY_DOUBLE: 'wavyDouble', + WD_UNDERLINE.DASH_LONG_HEAVY: 'dashLongHeavy', + } + val = underline_vals[value] + self.set(qn('w:val'), val) diff --git a/docx/text.py b/docx/text.py index cf2c87705..daf08c000 100644 --- a/docx/text.py +++ b/docx/text.py @@ -344,6 +344,10 @@ def underline(self): """ return self._r.underline + @underline.setter + def underline(self, value): + self._r.underline = value + @boolproperty def web_hidden(self): """ diff --git a/features/run-enum-props.feature b/features/run-enum-props.feature index 7124032b0..782918f1e 100644 --- a/features/run-enum-props.feature +++ b/features/run-enum-props.feature @@ -16,7 +16,6 @@ Feature: Query or apply enumerated run property | double | WD_UNDERLINE.DOUBLE | - @wip Scenario Outline: Change underline setting for a run Given a run having underline When I set the run underline to diff --git a/tests/test_text.py b/tests/test_text.py index 97773edb1..1c4fc8e4c 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -156,6 +156,11 @@ def it_knows_its_underline_type(self, underline_get_fixture): run, expected_value = underline_get_fixture assert run.underline == expected_value + def it_can_change_its_underline_type(self, underline_set_fixture): + run, underline, expected_xml = underline_set_fixture + run.underline = underline + assert run._r.xml == expected_xml + def it_can_add_text(self, add_text_fixture): run, text_str, expected_xml, Text_ = add_text_fixture _text = run.add_text(text_str) @@ -370,6 +375,25 @@ def underline_get_fixture(self, request): run = Run(r) return run, expected_prop_value + @pytest.fixture(params=[ + (None, True, 'single'), + (None, False, 'none'), + (None, None, None), + (None, WD_UNDERLINE.SINGLE, 'single'), + (None, WD_UNDERLINE.WAVY, 'wave'), + ('single', True, 'single'), + ('single', False, 'none'), + ('single', None, None), + ('single', WD_UNDERLINE.SINGLE, 'single'), + ('single', WD_UNDERLINE.DOTTED, 'dotted'), + ]) + def underline_set_fixture(self, request): + before_val, underline, expected_val = request.param + r = self.r_bldr_with_underline(before_val).element + run = Run(r) + expected_xml = self.r_bldr_with_underline(expected_val).xml() + return run, underline, expected_xml + # fixture components --------------------------------------------- @pytest.fixture From 22526778a246f4fcb85e882b6e22fe30d5a8955e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 10 May 2014 14:56:56 -0700 Subject: [PATCH 014/809] release: v0.5.3 add feature #19 Run.underline --- HISTORY.rst | 8 +++++++- docx/__init__.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index f18734895..6c371c336 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,10 +3,16 @@ Release History --------------- +0.5.3 (2014-05-10) +++++++++++++++++++ + +- Add feature #19: Run.underline property + + 0.5.2 (2014-05-06) ++++++++++++++++++ -- Add #17 feature: character style +- Add feature #17: character style 0.5.1 (2014-04-02) diff --git a/docx/__init__.py b/docx/__init__.py index 0d67a12fc..b6147da04 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.5.2' +__version__ = '0.5.3' # register custom Part classes with opc package reader From efc8678b550321e83d28cbeca73c892807ca3741 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 10 May 2014 14:56:56 -0700 Subject: [PATCH 015/809] docs: fix screenshot layout anomaly CSS on RTD.org must be different or something because screenshot on docs/index.rst was showing up teeny-tiny. Add some column width by adding width to left column of table the screenshot appears in. --- docs/index.rst | 64 +++++++++++++++++++++++++------------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 3add723b2..b7264f131 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,48 +14,48 @@ What it can do Here's an example of what |docx| can do: -====== ====================================================================== -|img| :: +============================================ =============================================================== +|img| :: - from docx import Document - from docx.shared import Inches + from docx import Document + from docx.shared import Inches - document = Document() + document = Document() - document.add_heading('Document Title', 0) + document.add_heading('Document Title', 0) - p = document.add_paragraph('A plain paragraph having some ') - p.add_run('bold').bold = True - p.add_run(' and some ') - p.add_run('italic.').italic = True + p = document.add_paragraph('A plain paragraph having some ') + p.add_run('bold').bold = True + p.add_run(' and some ') + p.add_run('italic.').italic = True - document.add_heading('Heading, level 1', level=1) - document.add_paragraph('Intense quote', style='IntenseQuote') + document.add_heading('Heading, level 1', level=1) + document.add_paragraph('Intense quote', style='IntenseQuote') - document.add_paragraph( - 'first item in unordered list', style='ListBullet' - ) - document.add_paragraph( - 'first item in ordered list', style='ListNumber' - ) + document.add_paragraph( + 'first item in unordered list', style='ListBullet' + ) + document.add_paragraph( + 'first item in ordered list', style='ListNumber' + ) - document.add_picture('monty-truth.png', width=Inches(1.25)) + document.add_picture('monty-truth.png', width=Inches(1.25)) - table = document.add_table(rows=1, cols=3) - hdr_cells = table.rows[0].cells - hdr_cells[0].text = 'Qty' - hdr_cells[1].text = 'Id' - hdr_cells[2].text = 'Desc' - for item in recordset: - row_cells = table.add_row().cells - row_cells[0].text = str(item.qty) - row_cells[1].text = str(item.id) - row_cells[2].text = item.desc + table = document.add_table(rows=1, cols=3) + hdr_cells = table.rows[0].cells + hdr_cells[0].text = 'Qty' + hdr_cells[1].text = 'Id' + hdr_cells[2].text = 'Desc' + for item in recordset: + row_cells = table.add_row().cells + row_cells[0].text = str(item.qty) + row_cells[1].text = str(item.id) + row_cells[2].text = item.desc - document.add_page_break() + document.add_page_break() - document.save('demo.docx') -====== ====================================================================== + document.save('demo.docx') +============================================ =============================================================== User Guide From e72292872a71fb2fdaf6c825239babbc7777c084 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 12 May 2014 00:10:46 -0700 Subject: [PATCH 016/809] acpt: fix py33 behave error on helpers import Also, removed Pillow dependency for tox environments. --- features/steps/text.py | 2 +- tox.ini | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/features/steps/text.py b/features/steps/text.py index ead0c1a15..134a9f53e 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -12,7 +12,7 @@ from docx.enum.text import WD_BREAK, WD_UNDERLINE from docx.oxml.shared import qn -from .helpers import test_docx, test_text +from helpers import test_docx, test_text # given =================================================== diff --git a/tox.ini b/tox.ini index d183c3d63..fd44ece2a 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,6 @@ deps = lxml mock pytest - Pillow commands = py.test -qx From 06de6c9ca2266867cb6818294fd0964cd38e6082 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 10 May 2014 21:59:25 -0700 Subject: [PATCH 017/809] enum: transplant docx.enum.base module Transplanted from python-pptx, with some changes to fix Python 3.3 incompatibilities. Enables full-featured enumerations to be defined declaratively. Features include setter value validation, str() value elaborated with symbolic name of value, alias names for an enumeration, mapping to and from XML enumeration (ST_*) values, and auto generation of enumeration documentation page, in addition to the base symbolic reference to an enumeration value. --- docx/enum/base.py | 348 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_enum.py | 99 +++++++++++++ 2 files changed, 447 insertions(+) create mode 100644 docx/enum/base.py create mode 100644 tests/test_enum.py diff --git a/docx/enum/base.py b/docx/enum/base.py new file mode 100644 index 000000000..471402850 --- /dev/null +++ b/docx/enum/base.py @@ -0,0 +1,348 @@ +# encoding: utf-8 + +""" +Base classes and other objects used by enumerations +""" + +from __future__ import absolute_import, print_function + +import sys +import textwrap + + +def alias(*aliases): + """ + Decorating a class with @alias('FOO', 'BAR', ..) allows the class to + be referenced by each of the names provided as arguments. + """ + def decorator(cls): + # alias must be set in globals from caller's frame + caller = sys._getframe(1) + globals_dict = caller.f_globals + for alias in aliases: + globals_dict[alias] = cls + return cls + return decorator + + +class _DocsPageFormatter(object): + """ + Formats a RestructuredText documention page (string) for the enumeration + class parts passed to the constructor. An immutable one-shot service + object. + """ + def __init__(self, clsname, clsdict): + self._clsname = clsname + self._clsdict = clsdict + + @property + def page_str(self): + """ + The RestructuredText documentation page for the enumeration. This is + the only API member for the class. + """ + tmpl = '.. _%s:\n\n%s\n\n%s\n\n----\n\n%s' + components = ( + self._ms_name, self._page_title, self._intro_text, + self._member_defs + ) + return tmpl % components + + @property + def _intro_text(self): + """ + The docstring of the enumeration, formatted for use at the top of the + documentation page + """ + try: + cls_docstring = self._clsdict['__doc__'] + except KeyError: + cls_docstring = '' + return textwrap.dedent(cls_docstring).strip() + + def _member_def(self, member): + """ + Return an individual member definition formatted as an RST glossary + entry, wrapped to fit within 78 columns. + """ + member_docstring = textwrap.dedent(member.docstring).strip() + member_docstring = textwrap.fill( + member_docstring, width=78, initial_indent=' '*4, + subsequent_indent=' '*4 + ) + return '%s\n%s\n' % (member.name, member_docstring) + + @property + def _member_defs(self): + """ + A single string containing the aggregated member definitions section + of the documentation page + """ + members = self._clsdict['__members__'] + member_defs = [ + self._member_def(member) for member in members + if member.name is not None + ] + return '\n'.join(member_defs) + + @property + def _ms_name(self): + """ + The Microsoft API name for this enumeration + """ + return self._clsdict['__ms_name__'] + + @property + 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) + return '``%s``\n%s' % (self._clsname, title_underscore) + + +class MetaEnumeration(type): + """ + The metaclass for Enumeration and its subclasses. Adds a name for each + named member and compiles state needed by the enumeration class to + respond to other attribute gets + """ + def __new__(meta, clsname, bases, clsdict): + meta._add_enum_members(clsdict) + meta._collect_valid_settings(clsdict) + meta._generate_docs_page(clsname, clsdict) + return type.__new__(meta, clsname, bases, clsdict) + + @classmethod + def _add_enum_members(meta, clsdict): + """ + Dispatch ``.add_to_enum()`` call to each member so it can do its + thing to properly add itself to the enumeration class. This + delegation allows member sub-classes to add specialized behaviors. + """ + enum_members = clsdict['__members__'] + for member in enum_members: + member.add_to_enum(clsdict) + + @classmethod + def _collect_valid_settings(meta, clsdict): + """ + Return a sequence containing the enumeration values that are valid + assignment values. Return-only values are excluded. + """ + enum_members = clsdict['__members__'] + valid_settings = [] + for member in enum_members: + valid_settings.extend(member.valid_settings) + clsdict['_valid_settings'] = valid_settings + + @classmethod + def _generate_docs_page(meta, clsname, clsdict): + """ + Return the RST documentation page for the enumeration. + """ + clsdict['__docs_rst__'] = ( + _DocsPageFormatter(clsname, clsdict).page_str + ) + + +class EnumerationBase(object): + """ + Base class for all enumerations, used directly for enumerations requiring + only basic behavior. It's __dict__ is used below in the Python 2+3 + compatible metaclass definition. + """ + __members__ = () + __ms_name__ = '' + + @classmethod + def is_valid_setting(cls, value): + """ + Return |True| if *value* is an assignable value, |False| if it is + a return value-only member or not a member value. + """ + return value in cls._valid_settings + + +Enumeration = MetaEnumeration( + 'Enumeration', (object,), dict(EnumerationBase.__dict__) +) + + +class XmlEnumeration(Enumeration): + """ + Provides ``to_xml()`` and ``from_xml()`` methods in addition to base + enumeration features + """ + __members__ = () + __ms_name__ = '' + + @classmethod + def from_xml(cls, xml_val): + """ + Return the enumeration member corresponding to the XML value + *xml_val*. + """ + return cls._xml_to_member[xml_val] + + @classmethod + def to_xml(cls, enum_val): + """ + Return the XML value of the enumeration value *enum_val*. + """ + return cls._member_to_xml[enum_val] + + +class EnumMember(object): + """ + Used in the enumeration class definition to define a member value and its + mappings + """ + def __init__(self, name, value, docstring): + self._name = name + if isinstance(value, int): + value = EnumValue(name, value, docstring) + self._value = value + self._docstring = docstring + + def add_to_enum(self, clsdict): + """ + Add a name to *clsdict* for this member. + """ + self.register_name(clsdict) + + @property + def docstring(self): + """ + The description of this member + """ + return self._docstring + + @property + def name(self): + """ + The distinguishing name of this member within the enumeration class, + e.g. 'MIDDLE' for MSO_VERTICAL_ANCHOR.MIDDLE, if this is a named + member. Otherwise the primitive value such as |None|, |True| or + |False|. + """ + return self._name + + def register_name(self, clsdict): + """ + Add a member name to the class dict *clsdict* containing the value of + this member object. Where the name of this object is None, do + nothing; this allows out-of-band values to be defined without adding + a name to the class dict. + """ + if self.name is None: + return + clsdict[self.name] = self.value + + @property + def valid_settings(self): + """ + A sequence containing the values valid for assignment for this + member. May be zero, one, or more in number. + """ + return (self._value,) + + @property + def value(self): + """ + The enumeration value for this member, often an instance of + EnumValue, but may be a primitive value such as |None|. + """ + return self._value + + +class EnumValue(int): + """ + A named enumeration value, providing __str__ and __doc__ string values + for its symbolic name and description, respectively. Subclasses int, so + behaves as a regular int unless the strings are asked for. + """ + def __new__(cls, member_name, int_value, docstring): + return super(EnumValue, cls).__new__(cls, int_value) + + def __init__(self, member_name, int_value, docstring): + super(EnumValue, self).__init__() + self._member_name = member_name + self._docstring = docstring + + @property + def __doc__(self): + """ + The description of this enumeration member + """ + return self._docstring.strip() + + def __str__(self): + """ + The symbolic name and string value of this member, e.g. 'MIDDLE (3)' + """ + return "%s (%d)" % (self._member_name, int(self)) + + +class ReturnValueOnlyEnumMember(EnumMember): + """ + Used to define a member of an enumeration that is only valid as a query + result and is not valid as a setting, e.g. MSO_VERTICAL_ANCHOR.MIXED (-2) + """ + @property + def valid_settings(self): + """ + No settings are valid for a return-only value. + """ + return () + + +class XmlMappedEnumMember(EnumMember): + """ + Used to define a member whose value maps to an XML attribute value. + """ + def __init__(self, name, value, xml_value, docstring): + super(XmlMappedEnumMember, self).__init__(name, value, docstring) + self._xml_value = xml_value + + def add_to_enum(self, clsdict): + """ + Compile XML mappings in addition to base add behavior. + """ + super(XmlMappedEnumMember, self).add_to_enum(clsdict) + self.register_xml_mapping(clsdict) + + def register_xml_mapping(self, clsdict): + """ + Add XML mappings to the enumeration class state for this member. + """ + member_to_xml = self._get_or_add_member_to_xml(clsdict) + member_to_xml[self.value] = self.xml_value + xml_to_member = self._get_or_add_xml_to_member(clsdict) + xml_to_member[self.xml_value] = self.value + + @property + def xml_value(self): + """ + The XML attribute value that corresponds to this enumeration value + """ + return self._xml_value + + @staticmethod + def _get_or_add_member_to_xml(clsdict): + """ + Add the enum -> xml value mapping to the enumeration class state + """ + if '_member_to_xml' not in clsdict: + clsdict['_member_to_xml'] = dict() + return clsdict['_member_to_xml'] + + @staticmethod + def _get_or_add_xml_to_member(clsdict): + """ + Add the xml -> enum value mapping to the enumeration class state + """ + if '_xml_to_member' not in clsdict: + clsdict['_xml_to_member'] = dict() + return clsdict['_xml_to_member'] diff --git a/tests/test_enum.py b/tests/test_enum.py new file mode 100644 index 000000000..06a9e80f3 --- /dev/null +++ b/tests/test_enum.py @@ -0,0 +1,99 @@ +# encoding: utf-8 + +""" +Test suite for docx.enum module, focused on base classes. Configured a little +differently because of the meta-programming, the two enumeration classes at +the top constitute the entire fixture and the tests themselves just make +assertions on those. +""" + +from __future__ import absolute_import, print_function + +import pytest + +from docx.enum.base import ( + alias, Enumeration, EnumMember, ReturnValueOnlyEnumMember, + XmlEnumeration, XmlMappedEnumMember +) + + +@alias('BARFOO') +class FOOBAR(Enumeration): + """ + Enumeration docstring + """ + + __ms_name__ = 'MsoFoobar' + + __url__ = 'http://msdn.microsoft.com/foobar.aspx' + + __members__ = ( + EnumMember(None, None, 'No setting/remove setting'), + EnumMember('READ_WRITE', 1, 'Readable and settable'), + ReturnValueOnlyEnumMember('READ_ONLY', -2, 'Return value only'), + ) + + +@alias('XML-FU') +class XMLFOO(XmlEnumeration): + """ + XmlEnumeration docstring + """ + + __ms_name__ = 'MsoXmlFoobar' + + __url__ = 'http://msdn.microsoft.com/msoxmlfoobar.aspx' + + __members__ = ( + XmlMappedEnumMember(None, None, None, 'No setting'), + XmlMappedEnumMember('XML_RW', 42, 'attrVal', 'Read/write setting'), + ReturnValueOnlyEnumMember('RO', -2, 'Return value only;'), + ) + + +class DescribeEnumeration(object): + + def it_has_the_right_metaclass(self): + assert type(FOOBAR).__name__ == 'MetaEnumeration' + + def it_provides_an_EnumValue_instance_for_each_named_member(self): + with pytest.raises(AttributeError): + getattr(FOOBAR, 'None') + for obj in (FOOBAR.READ_WRITE, FOOBAR.READ_ONLY): + assert type(obj).__name__ == 'EnumValue' + + def it_provides_the_enumeration_value_for_each_named_member(self): + assert FOOBAR.READ_WRITE == 1 + assert FOOBAR.READ_ONLY == -2 + + def it_knows_if_a_setting_is_valid(self): + assert FOOBAR.is_valid_setting(None) + assert FOOBAR.is_valid_setting(FOOBAR.READ_WRITE) + assert not FOOBAR.is_valid_setting('foobar') + assert not FOOBAR.is_valid_setting(FOOBAR.READ_ONLY) + + def it_can_be_referred_to_by_a_convenience_alias_if_defined(self): + assert BARFOO is FOOBAR # noqa + + +class DescribeEnumValue(object): + + def it_provides_its_symbolic_name_as_its_string_value(self): + assert ('%s' % FOOBAR.READ_WRITE) == 'READ_WRITE (1)' + + def it_provides_its_description_as_its_docstring(self): + assert FOOBAR.READ_ONLY.__doc__ == 'Return value only' + + +class DescribeXmlEnumeration(object): + + def it_knows_the_XML_value_for_each_of_its_xml_members(self): + assert XMLFOO.to_xml(XMLFOO.XML_RW) == 'attrVal' + assert XMLFOO.to_xml(42) == 'attrVal' + with pytest.raises(KeyError): + XMLFOO.to_xml(XMLFOO.RO) + + def it_can_map_each_of_its_xml_members_from_the_XML_value(self): + assert XMLFOO.from_xml(None) is None + assert XMLFOO.from_xml('attrVal') == XMLFOO.XML_RW + assert str(XMLFOO.from_xml('attrVal')) == 'XML_RW (42)' From e492e370778d547029f2094f5e828ee99e11b717 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 11 May 2014 13:59:12 -0700 Subject: [PATCH 018/809] enum: convert WD_UNDERLINE to XmlEnumeration --- docx/enum/text.py | 93 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 72 insertions(+), 21 deletions(-) diff --git a/docx/enum/text.py b/docx/enum/text.py index 8535fbcc1..11f8033f0 100644 --- a/docx/enum/text.py +++ b/docx/enum/text.py @@ -6,6 +6,8 @@ from __future__ import absolute_import, print_function, unicode_literals +from .base import XmlEnumeration, XmlMappedEnumMember + class WD_BREAK_TYPE(object): """ @@ -27,26 +29,75 @@ class WD_BREAK_TYPE(object): WD_BREAK = WD_BREAK_TYPE -class WD_UNDERLINE(object): +class WD_UNDERLINE(XmlEnumeration): """ - Corresponds to WdUnderline enumeration - http://msdn.microsoft.com/en-us/library/office/ff822388.aspx + Specifies the style of underline applied to a run of characters. """ - NONE = 0 - SINGLE = 1 - WORDS = 2 - DOUBLE = 3 - DOTTED = 4 - THICK = 6 - DASH = 7 - DOT_DASH = 9 - DOT_DOT_DASH = 10 - WAVY = 11 - DOTTED_HEAVY = 20 - DASH_HEAVY = 23 - DOT_DASH_HEAVY = 25 - DOT_DOT_DASH_HEAVY = 26 - WAVY_HEAVY = 27 - DASH_LONG = 39 - WAVY_DOUBLE = 43 - DASH_LONG_HEAVY = 55 + + __ms_name__ = 'WdUnderline' + + __url__ = 'http://msdn.microsoft.com/en-us/library/office/ff822388.aspx' + + __members__ = ( + XmlMappedEnumMember( + 'NONE', 0, 'none', 'No underline. This setting overrides any inh' + 'erited underline value, so can be used to remove underline from' + ' a run that inherits underlining from its containing paragraph.' + ), + XmlMappedEnumMember( + 'SINGLE', 1, 'single', 'A single line (default). Note that this ' + 'setting is write-only in the sense that |True| (rather than 1) ' + 'is returned for a run having this setting.' + ), + XmlMappedEnumMember( + 'WORDS', 2, 'words', 'Underline individual words only.' + ), + XmlMappedEnumMember( + 'DOUBLE', 3, 'double', 'A double line.' + ), + XmlMappedEnumMember( + 'DOTTED', 4, 'dotted', 'Dots.' + ), + XmlMappedEnumMember( + 'THICK', 6, 'thick', 'A single thick line.' + ), + XmlMappedEnumMember( + 'DASH', 7, 'dash', 'Dashes.' + ), + XmlMappedEnumMember( + 'DOT_DASH', 9, 'dotDash', 'Alternating dots and dashes.' + ), + XmlMappedEnumMember( + 'DOT_DOT_DASH', 10, 'dotDotDash', 'An alternating dot-dot-dash p' + 'attern.' + ), + XmlMappedEnumMember( + 'WAVY', 11, 'wave', 'A single wavy line.' + ), + XmlMappedEnumMember( + 'DOTTED_HEAVY', 20, 'dottedHeavy', 'Heavy dots.' + ), + XmlMappedEnumMember( + 'DASH_HEAVY', 23, 'dashedHeavy', 'Heavy dashes.' + ), + XmlMappedEnumMember( + 'DOT_DASH_HEAVY', 25, 'dashDotHeavy', 'Alternating heavy dots an' + 'd heavy dashes.' + ), + XmlMappedEnumMember( + 'DOT_DOT_DASH_HEAVY', 26, 'dashDotDotHeavy', 'An alternating hea' + 'vy dot-dot-dash pattern.' + ), + XmlMappedEnumMember( + 'WAVY_HEAVY', 27, 'wavyHeavy', 'A heavy wavy line.' + ), + XmlMappedEnumMember( + 'DASH_LONG', 39, 'dashLong', 'Long dashes.' + ), + XmlMappedEnumMember( + 'WAVY_DOUBLE', 43, 'wavyDouble', 'A double wavy line.' + ), + XmlMappedEnumMember( + 'DASH_LONG_HEAVY', 55, 'dashLongHeavy', 'Long heavy dashes.' + ), + ) From c46f822a4fe1adb9136aae6f624c23d245426ce0 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 11 May 2014 14:32:50 -0700 Subject: [PATCH 019/809] run: convert CT_Underline.val getter to from_xml() The new XmlEnumeration base class provides .from_xml() to encapsulate the mapping of XML attribute values to enumeration values, eliminating the need to place it in the oxml and repeat it in both directions, one for each of the getter and the setter. The predicate in the Run.underline getter unit test needs to be on 'is' rather than '==' in order to distinguish a `True` value from a 1 value and likewise for `False` vs. 0. For now the specialized mappings between WD_UNDERLINE.SINGLE and True and between WD_UNDERLINE.NONE and False are provided in the oxml getter and setter directly. Later it might make sense to add a mechanism to XmlEnumeration to allow adding get and set overrides/aliases, let's see how it goes and whether that happens often enough to warrant adding special-case functionality to XmlEnumeration. --- docx/oxml/text.py | 28 ++++++---------------------- tests/test_text.py | 2 +- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 37b1edefe..3872a51a3 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -835,29 +835,13 @@ def val(self): """ The underline type corresponding to the ``w:val`` attribute value. """ - underline_type_map = { - None: None, - 'none': False, - 'single': True, - 'words': WD_UNDERLINE.WORDS, - 'double': WD_UNDERLINE.DOUBLE, - 'dotted': WD_UNDERLINE.DOTTED, - 'thick': WD_UNDERLINE.THICK, - 'dash': WD_UNDERLINE.DASH, - 'dotDash': WD_UNDERLINE.DOT_DASH, - 'dotDotDash': WD_UNDERLINE.DOT_DOT_DASH, - 'wave': WD_UNDERLINE.WAVY, - 'dottedHeavy': WD_UNDERLINE.DOTTED_HEAVY, - 'dashedHeavy': WD_UNDERLINE.DASH_HEAVY, - 'dashDotHeavy': WD_UNDERLINE.DOT_DASH_HEAVY, - 'dashDotDotHeavy': WD_UNDERLINE.DOT_DOT_DASH_HEAVY, - 'wavyHeavy': WD_UNDERLINE.WAVY_HEAVY, - 'dashLong': WD_UNDERLINE.DASH_LONG, - 'wavyDouble': WD_UNDERLINE.WAVY_DOUBLE, - 'dashLongHeavy': WD_UNDERLINE.DASH_LONG_HEAVY, - } val = self.get(qn('w:val')) - return underline_type_map[val] + underline = WD_UNDERLINE.from_xml(val) + if underline == WD_UNDERLINE.SINGLE: + return True + if underline == WD_UNDERLINE.NONE: + return False + return underline @val.setter def val(self, value): diff --git a/tests/test_text.py b/tests/test_text.py index 1c4fc8e4c..fd2d4b60f 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -154,7 +154,7 @@ def it_can_change_its_character_style(self, style_set_fixture): def it_knows_its_underline_type(self, underline_get_fixture): run, expected_value = underline_get_fixture - assert run.underline == expected_value + assert run.underline is expected_value def it_can_change_its_underline_type(self, underline_set_fixture): run, underline, expected_xml = underline_set_fixture From 194f77dd4e8659e97ba2beadc9144d897006baab Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 11 May 2014 21:17:16 -0700 Subject: [PATCH 020/809] run: convert CT_Underline.val setter to to_xml() --- docx/oxml/text.py | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 3872a51a3..f4f8517a7 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -845,25 +845,13 @@ def val(self): @val.setter def val(self, value): - underline_vals = { - True: 'single', - False: 'none', - WD_UNDERLINE.WORDS: 'words', - WD_UNDERLINE.DOUBLE: 'double', - WD_UNDERLINE.DOTTED: 'dotted', - WD_UNDERLINE.THICK: 'thick', - WD_UNDERLINE.DASH: 'dash', - WD_UNDERLINE.DOT_DASH: 'dotDash', - WD_UNDERLINE.DOT_DOT_DASH: 'dotDotDash', - WD_UNDERLINE.WAVY: 'wave', - WD_UNDERLINE.DOTTED_HEAVY: 'dottedHeavy', - WD_UNDERLINE.DASH_HEAVY: 'dashedHeavy', - WD_UNDERLINE.DOT_DASH_HEAVY: 'dashDotHeavy', - WD_UNDERLINE.DOT_DOT_DASH_HEAVY: 'dashDotDotHeavy', - WD_UNDERLINE.WAVY_HEAVY: 'wavyHeavy', - WD_UNDERLINE.DASH_LONG: 'dashLong', - WD_UNDERLINE.WAVY_DOUBLE: 'wavyDouble', - WD_UNDERLINE.DASH_LONG_HEAVY: 'dashLongHeavy', - } - val = underline_vals[value] + # works fine without these two mappings, but only because True == 1 + # and False == 0, which happen to match the mapping for WD_UNDERLINE + # .SINGLE and .NONE respectively. + if value is True: + value = WD_UNDERLINE.SINGLE + elif value is False: + value = WD_UNDERLINE.NONE + + val = WD_UNDERLINE.to_xml(value) self.set(qn('w:val'), val) From 9fa9d0a4883f2219f23252efbb6b7cf5948228a3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 12 May 2014 23:36:14 -0700 Subject: [PATCH 021/809] run: add validator to Run.underline setter --- docx/enum/text.py | 4 ++++ docx/text.py | 5 ++++- tests/test_text.py | 13 +++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docx/enum/text.py b/docx/enum/text.py index 11f8033f0..33a435792 100644 --- a/docx/enum/text.py +++ b/docx/enum/text.py @@ -39,6 +39,10 @@ class WD_UNDERLINE(XmlEnumeration): __url__ = 'http://msdn.microsoft.com/en-us/library/office/ff822388.aspx' __members__ = ( + XmlMappedEnumMember( + None, None, None, 'Inherit underline setting from containing par' + 'agraph.' + ), XmlMappedEnumMember( 'NONE', 0, 'none', 'No underline. This setting overrides any inh' 'erited underline value, so can be used to remove underline from' diff --git a/docx/text.py b/docx/text.py index daf08c000..9f625c4df 100644 --- a/docx/text.py +++ b/docx/text.py @@ -6,7 +6,7 @@ from __future__ import absolute_import, print_function, unicode_literals -from docx.enum.text import WD_BREAK +from docx.enum.text import WD_BREAK, WD_UNDERLINE def boolproperty(f): @@ -346,6 +346,9 @@ def underline(self): @underline.setter def underline(self, value): + if not WD_UNDERLINE.is_valid_setting(value): + tmpl = "'%s' is not a valid setting for Run.underline" + raise ValueError(tmpl % value) self._r.underline = value @boolproperty diff --git a/tests/test_text.py b/tests/test_text.py index fd2d4b60f..f615de6c4 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -161,6 +161,12 @@ def it_can_change_its_underline_type(self, underline_set_fixture): run.underline = underline assert run._r.xml == expected_xml + def it_raises_on_assign_invalid_underline_type( + self, underline_raise_fixture): + run, underline = underline_raise_fixture + with pytest.raises(ValueError): + run.underline = underline + def it_can_add_text(self, add_text_fixture): run, text_str, expected_xml, Text_ = add_text_fixture _text = run.add_text(text_str) @@ -375,6 +381,13 @@ def underline_get_fixture(self, request): run = Run(r) return run, expected_prop_value + @pytest.fixture(params=['foobar', 42, 'single']) + def underline_raise_fixture(self, request): + underline = request.param + r = self.r_bldr_with_underline(None).element + run = Run(r) + return run, underline + @pytest.fixture(params=[ (None, True, 'single'), (None, False, 'none'), From fc965c2419013a50a27ed4ac72e479b79b6a3adf Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 13 May 2014 00:01:49 -0700 Subject: [PATCH 022/809] docs: add docs for WD_UNDERLINE enumeration --- docs/api/enum/WdUnderline.rst | 69 ++++++++++++++++++++++++ docs/api/enum/index.rst | 11 ++++ docs/dev/analysis/features/underline.rst | 3 ++ docs/index.rst | 1 + docx/enum/text.py | 10 ++-- docx/text.py | 16 +++--- 6 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 docs/api/enum/WdUnderline.rst create mode 100644 docs/api/enum/index.rst diff --git a/docs/api/enum/WdUnderline.rst b/docs/api/enum/WdUnderline.rst new file mode 100644 index 000000000..155d67125 --- /dev/null +++ b/docs/api/enum/WdUnderline.rst @@ -0,0 +1,69 @@ +.. _WdUnderline: + +``WD_UNDERLINE`` +================ + +Specifies the style of underline applied to a run of characters. + +---- + +NONE + No underline. This setting overrides any inherited underline value, so can + be used to remove underline from a run that inherits underlining from its + containing paragraph. Note this is not the same as assigning |None| to + Run.underline. |None| is a valid assignment value, but causes the run to + inherit its underline value. Assigning ``WD_UNDERLINE.NONE`` causes + underlining to be unconditionally turned off. + +SINGLE + A single line. Note that this setting is write-only in the sense that + |True| (rather than ``WD_UNDERLINE.SINGLE``) is returned for a run having + this setting. + +WORDS + Underline individual words only. + +DOUBLE + A double line. + +DOTTED + Dots. + +THICK + A single thick line. + +DASH + Dashes. + +DOT_DASH + Alternating dots and dashes. + +DOT_DOT_DASH + An alternating dot-dot-dash pattern. + +WAVY + A single wavy line. + +DOTTED_HEAVY + Heavy dots. + +DASH_HEAVY + Heavy dashes. + +DOT_DASH_HEAVY + Alternating heavy dots and heavy dashes. + +DOT_DOT_DASH_HEAVY + An alternating heavy dot-dot-dash pattern. + +WAVY_HEAVY + A heavy wavy line. + +DASH_LONG + Long dashes. + +WAVY_DOUBLE + A double wavy line. + +DASH_LONG_HEAVY + Long heavy dashes. diff --git a/docs/api/enum/index.rst b/docs/api/enum/index.rst new file mode 100644 index 000000000..d69c7d00e --- /dev/null +++ b/docs/api/enum/index.rst @@ -0,0 +1,11 @@ + +Enumerations +============ + +Documentation for the various enumerations used for |docx| property settings +can be found here: + +.. toctree:: + :titlesonly: + + WdUnderline diff --git a/docs/dev/analysis/features/underline.rst b/docs/dev/analysis/features/underline.rst index 0e206c1b0..6cad0c25e 100644 --- a/docs/dev/analysis/features/underline.rst +++ b/docs/dev/analysis/features/underline.rst @@ -87,6 +87,9 @@ Directly-applied no-underline, overrides inherited value:: Schema excerpt -------------- +Note that the ``w:val`` attribute on ``CT_Underline`` is optional. When it is +not present no underline appears on the run. + .. highlight:: xml :: diff --git a/docs/index.rst b/docs/index.rst index b7264f131..f751dcd16 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -84,6 +84,7 @@ API Documentation api/text api/shape api/shared + api/enum/index Contributor Guide diff --git a/docx/enum/text.py b/docx/enum/text.py index 33a435792..a7fd540e8 100644 --- a/docx/enum/text.py +++ b/docx/enum/text.py @@ -47,11 +47,15 @@ class WD_UNDERLINE(XmlEnumeration): 'NONE', 0, 'none', 'No underline. This setting overrides any inh' 'erited underline value, so can be used to remove underline from' ' a run that inherits underlining from its containing paragraph.' + ' Note this is not the same as assigning |None| to Run.underline' + '. |None| is a valid assignment value, but causes the run to inh' + 'erit its underline value. Assigning ``WD_UNDERLINE.NONE`` cause' + 's underlining to be unconditionally turned off.' ), XmlMappedEnumMember( - 'SINGLE', 1, 'single', 'A single line (default). Note that this ' - 'setting is write-only in the sense that |True| (rather than 1) ' - 'is returned for a run having this setting.' + 'SINGLE', 1, 'single', 'A single line. Note that this setting is' + 'write-only in the sense that |True| (rather than ``WD_UNDERLINE' + '.SINGLE``) is returned for a run having this setting.' ), XmlMappedEnumMember( 'WORDS', 2, 'words', 'Underline individual words only.' diff --git a/docx/text.py b/docx/text.py index 9f625c4df..ef5c0ab5c 100644 --- a/docx/text.py +++ b/docx/text.py @@ -333,14 +333,14 @@ def text(self): def underline(self): """ The underline style for this |Run|, one of |None|, |True|, |False|, - or a value from ``pptx.enum.text.WD_UNDERLINE``. A value of |None| - indicates the run has no directly-applied underline value and so will - inherit the underline value of its containing paragraph. Assigning - |None| to this property removes any directly-applied underline value. - A value of |False| indicates a directly-applied setting of no - underline, overriding any inherited value. A value of |True| - indicates single underline. The values from ``WD_UNDERLINE`` are used - to specify other outline styles such as double, wavy, and dotted. + or a value from :ref:`WdUnderline`. A value of |None| indicates the + run has no directly-applied underline value and so will inherit the + underline value of its containing paragraph. Assigning |None| to this + property removes any directly-applied underline value. A value of + |False| indicates a directly-applied setting of no underline, + overriding any inherited value. A value of |True| indicates single + underline. The values from ``WD_UNDERLINE`` are used to specify other + outline styles such as double, wavy, and dotted. """ return self._r.underline From f64f87be67264efc52c1b43c7eb0fb7e7ca2d685 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 02:02:32 -0700 Subject: [PATCH 023/809] oxml: extract nsmap to docx.oxml.ns --- docx/oxml/ns.py | 21 +++++++++++++++++++++ docx/oxml/parts/numbering.py | 5 ++--- docx/oxml/parts/styles.py | 3 ++- docx/oxml/shape.py | 7 +++---- docx/oxml/shared.py | 14 +------------- docx/parts/document.py | 3 ++- docx/shape.py | 6 +++--- 7 files changed, 34 insertions(+), 25 deletions(-) create mode 100644 docx/oxml/ns.py diff --git a/docx/oxml/ns.py b/docx/oxml/ns.py new file mode 100644 index 000000000..50f4bba5d --- /dev/null +++ b/docx/oxml/ns.py @@ -0,0 +1,21 @@ +# encoding: utf-8 + +""" +Namespace-related objects. +""" + +from __future__ import absolute_import, print_function, unicode_literals + + +nsmap = { + 'a': ('http://schemas.openxmlformats.org/drawingml/2006/main'), + 'c': ('http://schemas.openxmlformats.org/drawingml/2006/chart'), + 'dgm': ('http://schemas.openxmlformats.org/drawingml/2006/diagram'), + 'pic': ('http://schemas.openxmlformats.org/drawingml/2006/picture'), + 'r': ('http://schemas.openxmlformats.org/officeDocument/2006/relations' + 'hips'), + 'w': ('http://schemas.openxmlformats.org/wordprocessingml/2006/main'), + 'wp': ('http://schemas.openxmlformats.org/drawingml/2006/wordprocessing' + 'Drawing'), + 'xml': ('http://www.w3.org/XML/1998/namespace') +} diff --git a/docx/oxml/parts/numbering.py b/docx/oxml/parts/numbering.py index 82b4accf9..fa0377f9e 100644 --- a/docx/oxml/parts/numbering.py +++ b/docx/oxml/parts/numbering.py @@ -4,9 +4,8 @@ Custom element classes related to the numbering part """ -from docx.oxml.shared import ( - CT_DecimalNumber, nsmap, OxmlBaseElement, OxmlElement, qn -) +from ..shared import CT_DecimalNumber, OxmlBaseElement, OxmlElement, qn +from ..ns import nsmap class CT_Num(OxmlBaseElement): diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index 58fb6ecfe..115231578 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -4,7 +4,8 @@ Custom element classes related to the styles part """ -from docx.oxml.shared import nsmap, OxmlBaseElement, qn +from ..shared import OxmlBaseElement, qn +from ..ns import nsmap class CT_Style(OxmlBaseElement): diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index 542e472e4..751a8b7d2 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -4,10 +4,9 @@ Custom element classes for shape-related elements like ```` """ -from docx.oxml.shared import ( - nsmap, nspfxmap, OxmlBaseElement, OxmlElement, qn -) -from docx.shared import Emu +from .shared import nspfxmap, OxmlBaseElement, OxmlElement, qn +from ..shared import Emu +from .ns import nsmap class CT_Blip(OxmlBaseElement): diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index 7432b9847..4647a54df 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -9,21 +9,9 @@ import re from .exceptions import ValidationError +from .ns import nsmap -nsmap = { - 'a': ('http://schemas.openxmlformats.org/drawingml/2006/main'), - 'c': ('http://schemas.openxmlformats.org/drawingml/2006/chart'), - 'dgm': ('http://schemas.openxmlformats.org/drawingml/2006/diagram'), - 'pic': ('http://schemas.openxmlformats.org/drawingml/2006/picture'), - 'r': ('http://schemas.openxmlformats.org/officeDocument/2006/relations' - 'hips'), - 'w': ('http://schemas.openxmlformats.org/wordprocessingml/2006/main'), - 'wp': ('http://schemas.openxmlformats.org/drawingml/2006/wordprocessing' - 'Drawing'), - 'xml': ('http://www.w3.org/XML/1998/namespace') -} - # configure XML parser element_class_lookup = etree.ElementNamespaceClassLookup() oxml_parser = etree.XMLParser(remove_blank_text=True) diff --git a/docx/parts/document.py b/docx/parts/document.py index b0cfcb7be..c0a973c19 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -11,7 +11,8 @@ from ..opc.constants import RELATIONSHIP_TYPE as RT from ..opc.oxml import serialize_part_xml from ..opc.package import Part -from ..oxml.shared import nsmap, oxml_fromstring +from ..oxml.ns import nsmap +from ..oxml.shared import oxml_fromstring from ..shape import InlineShape from ..shared import lazyproperty, Parented from ..table import Table diff --git a/docx/shape.py b/docx/shape.py index 33371218d..c1fe9742a 100644 --- a/docx/shape.py +++ b/docx/shape.py @@ -9,9 +9,9 @@ absolute_import, division, print_function, unicode_literals ) -from docx.enum.shape import WD_INLINE_SHAPE -from docx.oxml.shape import CT_Inline, CT_Picture -from docx.oxml.shared import nsmap +from .enum.shape import WD_INLINE_SHAPE +from .oxml.shape import CT_Inline, CT_Picture +from .oxml.ns import nsmap class InlineShape(object): From 8af48cf0a06d95ad5b6d129e1e02b0337f9008a8 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 02:13:03 -0700 Subject: [PATCH 024/809] oxml: extract parser to docx.oxml register_custom_element_class() needs to go with to avoid a circular import. --- docx/oxml/__init__.py | 23 ++++++++++++++++++++++- docx/oxml/shared.py | 20 +++----------------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 820eeec1d..a2992c351 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -5,7 +5,28 @@ corresponding to Open XML elements. """ -from docx.oxml.shared import register_custom_element_class +from __future__ import absolute_import + +from lxml import etree + +from .ns import nsmap + + +# configure XML parser +element_class_lookup = etree.ElementNamespaceClassLookup() +oxml_parser = etree.XMLParser(remove_blank_text=True) +oxml_parser.set_element_class_lookup(element_class_lookup) + + +def register_custom_element_class(tag, cls): + """ + Register *cls* to be constructed when the oxml parser encounters an + element with matching *tag*. *tag* is a string of the form + ``nspfx:tagroot``, e.g. ``'w:document'``. + """ + nspfx, tagroot = tag.split(':') + namespace = element_class_lookup.get_namespace(nsmap[nspfx]) + namespace[tagroot] = cls # =========================================================================== diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index 4647a54df..b96d61dff 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -4,20 +4,17 @@ Objects shared by modules in the docx.oxml subpackage. """ +from __future__ import absolute_import + from lxml import etree import re +from . import oxml_parser from .exceptions import ValidationError from .ns import nsmap -# configure XML parser -element_class_lookup = etree.ElementNamespaceClassLookup() -oxml_parser = etree.XMLParser(remove_blank_text=True) -oxml_parser.set_element_class_lookup(element_class_lookup) - - # =========================================================================== # utility functions # =========================================================================== @@ -119,17 +116,6 @@ def qn(tag): return '{%s}%s' % (uri, tagroot) -def register_custom_element_class(tag, cls): - """ - Register *cls* to be constructed when the oxml parser encounters an - element with matching *tag*. *tag* is a string of the form - ``nspfx:tagroot``, e.g. ``'w:document'``. - """ - nspfx, tagroot = tag.split(':') - namespace = element_class_lookup.get_namespace(nsmap[nspfx]) - namespace[tagroot] = cls - - def serialize_for_reading(element): """ Serialize *element* to human-readable XML suitable for tests. No XML From 5ec3ac17363b2d34a19c009d790099f97fda7b3c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 02:24:51 -0700 Subject: [PATCH 025/809] oxml: extract NamespacePrefixedTag to docx.oxml.ns And added unit tests for NamespacePrefixedTag --- docx/oxml/ns.py | 51 ++++++++++++++++++++++++++++++++++++++ docx/oxml/shared.py | 52 +-------------------------------------- tests/oxml/test_ns.py | 57 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 51 deletions(-) create mode 100644 tests/oxml/test_ns.py diff --git a/docx/oxml/ns.py b/docx/oxml/ns.py index 50f4bba5d..55796b805 100644 --- a/docx/oxml/ns.py +++ b/docx/oxml/ns.py @@ -19,3 +19,54 @@ 'Drawing'), 'xml': ('http://www.w3.org/XML/1998/namespace') } + + +class NamespacePrefixedTag(str): + """ + Value object that knows the semantics of an XML tag having a namespace + prefix. + """ + def __new__(cls, nstag, *args): + return super(NamespacePrefixedTag, cls).__new__(cls, nstag) + + def __init__(self, nstag): + self._pfx, self._local_part = nstag.split(':') + self._ns_uri = nsmap[self._pfx] + + @property + def clark_name(self): + return '{%s}%s' % (self._ns_uri, self._local_part) + + @property + def local_part(self): + """ + Return the local part of the tag as a string. E.g. 'foobar' is + returned for tag 'f:foobar'. + """ + return self._local_part + + @property + def nsmap(self): + """ + Return a dict having a single member, mapping the namespace prefix of + this tag to it's namespace name (e.g. {'f': 'http://foo/bar'}). This + is handy for passing to xpath calls and other uses. + """ + return {self._pfx: self._ns_uri} + + @property + def nspfx(self): + """ + Return the string namespace prefix for the tag, e.g. 'f' is returned + for tag 'f:foobar'. + """ + return self._pfx + + @property + def nsuri(self): + """ + Return the namespace URI for the tag, e.g. 'http://foo/bar' would be + returned for tag 'f:foobar' if the 'f' prefix maps to + 'http://foo/bar' in nsmap. + """ + return self._ns_uri diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index b96d61dff..f8c88f7f3 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -12,63 +12,13 @@ from . import oxml_parser from .exceptions import ValidationError -from .ns import nsmap +from .ns import NamespacePrefixedTag, nsmap # =========================================================================== # utility functions # =========================================================================== -class NamespacePrefixedTag(str): - """ - Value object that knows the semantics of an XML tag having a namespace - prefix. - """ - def __new__(cls, nstag, *args): - return super(NamespacePrefixedTag, cls).__new__(cls, nstag) - - def __init__(self, nstag): - self._pfx, self._local_part = nstag.split(':') - self._ns_uri = nsmap[self._pfx] - - @property - def clark_name(self): - return '{%s}%s' % (self._ns_uri, self._local_part) - - @property - def local_part(self): - """ - Return the local part of the tag as a string. E.g. 'foobar' is - returned for tag 'f:foobar'. - """ - return self._local_part - - @property - def nsmap(self): - """ - Return a dict having a single member, mapping the namespace prefix of - this tag to it's namespace name (e.g. {'f': 'http://foo/bar'}). This - is handy for passing to xpath calls and other uses. - """ - return {self._pfx: self._ns_uri} - - @property - def nspfx(self): - """ - Return the string namespace prefix for the tag, e.g. 'f' is returned - for tag 'f:foobar'. - """ - return self._pfx - - @property - def nsuri(self): - """ - Return the namespace URI for the tag, e.g. 'http://foo/bar' would be - returned for tag 'f:foobar' if the 'f' prefix maps to - 'http://foo/bar' in nsmap. - """ - return self._ns_uri - def nsdecls(*prefixes): return ' '.join(['xmlns:%s="%s"' % (pfx, nsmap[pfx]) for pfx in prefixes]) diff --git a/tests/oxml/test_ns.py b/tests/oxml/test_ns.py new file mode 100644 index 000000000..630e70f46 --- /dev/null +++ b/tests/oxml/test_ns.py @@ -0,0 +1,57 @@ +# encoding: utf-8 + +""" +Test suite for docx.oxml.ns +""" + +from __future__ import absolute_import, print_function, unicode_literals + +import pytest + +from docx.oxml.ns import NamespacePrefixedTag + + +class DescribeNamespacePrefixedTag(object): + + def it_behaves_like_a_string_when_you_want_it_to(self, nsptag): + s = '- %s -' % nsptag + assert s == '- a:foobar -' + + def it_knows_its_clark_name(self, nsptag, clark_name): + assert nsptag.clark_name == clark_name + + def it_knows_its_local_part(self, nsptag, local_part): + assert nsptag.local_part == local_part + + def it_can_compose_a_single_entry_nsmap_for_itself( + self, nsptag, namespace_uri_a): + expected_nsmap = {'a': namespace_uri_a} + assert nsptag.nsmap == expected_nsmap + + def it_knows_its_namespace_prefix(self, nsptag): + assert nsptag.nspfx == 'a' + + def it_knows_its_namespace_uri(self, nsptag, namespace_uri_a): + assert nsptag.nsuri == namespace_uri_a + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def clark_name(self, namespace_uri_a, local_part): + return '{%s}%s' % (namespace_uri_a, local_part) + + @pytest.fixture + def local_part(self): + return 'foobar' + + @pytest.fixture + def namespace_uri_a(self): + return 'http://schemas.openxmlformats.org/drawingml/2006/main' + + @pytest.fixture + def nsptag(self, nsptag_str): + return NamespacePrefixedTag(nsptag_str) + + @pytest.fixture + def nsptag_str(self, local_part): + return 'a:%s' % local_part From 337734f67d4d62f3e966adcb2f3eb8c80e8d848a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 02:32:04 -0700 Subject: [PATCH 026/809] oxml: extract nsdecls() to docx.oxml.ns --- docx/oxml/ns.py | 8 ++++++++ docx/oxml/shared.py | 4 ---- docx/oxml/text.py | 9 +++++---- tests/unitdata.py | 3 ++- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/docx/oxml/ns.py b/docx/oxml/ns.py index 55796b805..d71ac1dd2 100644 --- a/docx/oxml/ns.py +++ b/docx/oxml/ns.py @@ -70,3 +70,11 @@ def nsuri(self): 'http://foo/bar' in nsmap. """ return self._ns_uri + + +def nsdecls(*prefixes): + """ + Return a string containing a namespace declaration for each of the + namespace prefix strings, e.g. 'p', 'ct', passed as *prefixes*. + """ + return ' '.join(['xmlns:%s="%s"' % (pfx, nsmap[pfx]) for pfx in prefixes]) diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index f8c88f7f3..e26cd21f4 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -20,10 +20,6 @@ # =========================================================================== -def nsdecls(*prefixes): - return ' '.join(['xmlns:%s="%s"' % (pfx, nsmap[pfx]) for pfx in prefixes]) - - def nspfxmap(*nspfxs): """ Return a dict containing the subset namespace prefix mappings specified by diff --git a/docx/oxml/text.py b/docx/oxml/text.py index f4f8517a7..c83f2248e 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -5,10 +5,11 @@ (CT_R). """ -from docx.enum.text import WD_UNDERLINE -from docx.oxml.parts.numbering import CT_NumPr -from docx.oxml.shared import ( - CT_String, nsdecls, OxmlBaseElement, OxmlElement, oxml_fromstring, qn +from ..enum.text import WD_UNDERLINE +from .ns import nsdecls +from .parts.numbering import CT_NumPr +from .shared import ( + CT_String, OxmlBaseElement, OxmlElement, oxml_fromstring, qn ) diff --git a/tests/unitdata.py b/tests/unitdata.py index 41aaa7309..924bdf829 100644 --- a/tests/unitdata.py +++ b/tests/unitdata.py @@ -6,7 +6,8 @@ from __future__ import absolute_import, print_function, unicode_literals -from docx.oxml.shared import nsdecls, oxml_fromstring +from docx.oxml.ns import nsdecls +from docx.oxml.shared import oxml_fromstring class BaseBuilder(object): From f4b305a9136d46658df5c3a51e152872ea0e785a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 02:51:06 -0700 Subject: [PATCH 027/809] oxml: extract nspfxmap() to docx.oxml.ns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes to OxmlElement were just to satisfy flake8, although it’s probably a little bit better style anyway. --- docx/oxml/ns.py | 9 +++++++++ docx/oxml/shape.py | 4 ++-- docx/oxml/shared.py | 13 ++----------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docx/oxml/ns.py b/docx/oxml/ns.py index d71ac1dd2..561ad4683 100644 --- a/docx/oxml/ns.py +++ b/docx/oxml/ns.py @@ -78,3 +78,12 @@ def nsdecls(*prefixes): namespace prefix strings, e.g. 'p', 'ct', passed as *prefixes*. """ return ' '.join(['xmlns:%s="%s"' % (pfx, nsmap[pfx]) for pfx in prefixes]) + + +def nspfxmap(*nspfxs): + """ + Return a dict containing the subset namespace prefix mappings specified by + *nspfxs*. Any number of namespace prefixes can be supplied, e.g. + namespaces('a', 'r', 'p'). + """ + return dict((pfx, nsmap[pfx]) for pfx in nspfxs) diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index 751a8b7d2..a5fb024fc 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -4,9 +4,9 @@ Custom element classes for shape-related elements like ```` """ -from .shared import nspfxmap, OxmlBaseElement, OxmlElement, qn +from .shared import OxmlBaseElement, OxmlElement, qn from ..shared import Emu -from .ns import nsmap +from .ns import nsmap, nspfxmap class CT_Blip(OxmlBaseElement): diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index e26cd21f4..222b278b7 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -20,15 +20,6 @@ # =========================================================================== -def nspfxmap(*nspfxs): - """ - Return a dict containing the subset namespace prefix mappings specified by - *nspfxs*. Any number of namespace prefixes can be supplied, e.g. - namespaces('a', 'r', 'p'). - """ - return dict((pfx, nsmap[pfx]) for pfx in nspfxs) - - def OxmlElement(nsptag_str, attrs=None, nsmap=None): """ Return a 'loose' lxml element having the tag specified by *nsptag_str*. @@ -38,9 +29,9 @@ def OxmlElement(nsptag_str, attrs=None, nsmap=None): provided as *attrs*; they are set if present. """ nsptag = NamespacePrefixedTag(nsptag_str) - nsmap = nsmap if nsmap is not None else nsptag.nsmap + _nsmap = nsptag.nsmap if nsmap is None else nsmap return oxml_parser.makeelement( - nsptag.clark_name, attrib=attrs, nsmap=nsmap + nsptag.clark_name, attrib=attrs, nsmap=_nsmap ) From 06ebca383a42b1237470e72168d2896a9be58ca9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 03:00:53 -0700 Subject: [PATCH 028/809] oxml: extract qn() to docx.oxml.ns --- docx/oxml/ns.py | 11 +++++++++++ docx/oxml/parts/document.py | 7 ++++--- docx/oxml/parts/numbering.py | 4 ++-- docx/oxml/parts/styles.py | 4 ++-- docx/oxml/shape.py | 4 ++-- docx/oxml/shared.py | 13 +------------ docx/oxml/table.py | 5 ++--- docx/oxml/text.py | 6 ++---- features/steps/text.py | 2 +- 9 files changed, 27 insertions(+), 29 deletions(-) diff --git a/docx/oxml/ns.py b/docx/oxml/ns.py index 561ad4683..0a8a15a34 100644 --- a/docx/oxml/ns.py +++ b/docx/oxml/ns.py @@ -87,3 +87,14 @@ def nspfxmap(*nspfxs): namespaces('a', 'r', 'p'). """ return dict((pfx, nsmap[pfx]) for pfx in nspfxs) + + +def qn(tag): + """ + Stands for "qualified name", a utility function to turn a namespace + prefixed tag name into a Clark-notation qualified tag name for lxml. For + example, ``qn('p:cSld')`` returns ``'{http://schemas.../main}cSld'``. + """ + prefix, tagroot = tag.split(':') + uri = nsmap[prefix] + return '{%s}%s' % (uri, tagroot) diff --git a/docx/oxml/parts/document.py b/docx/oxml/parts/document.py index 31b8e4f1a..4bb641bad 100644 --- a/docx/oxml/parts/document.py +++ b/docx/oxml/parts/document.py @@ -5,9 +5,10 @@ . """ -from docx.oxml.shared import OxmlBaseElement, qn -from docx.oxml.table import CT_Tbl -from docx.oxml.text import CT_P +from ..ns import qn +from ..shared import OxmlBaseElement +from ..table import CT_Tbl +from ..text import CT_P class CT_Document(OxmlBaseElement): diff --git a/docx/oxml/parts/numbering.py b/docx/oxml/parts/numbering.py index fa0377f9e..c67d97274 100644 --- a/docx/oxml/parts/numbering.py +++ b/docx/oxml/parts/numbering.py @@ -4,8 +4,8 @@ Custom element classes related to the numbering part """ -from ..shared import CT_DecimalNumber, OxmlBaseElement, OxmlElement, qn -from ..ns import nsmap +from ..shared import CT_DecimalNumber, OxmlBaseElement, OxmlElement +from ..ns import nsmap, qn class CT_Num(OxmlBaseElement): diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index 115231578..28235ef5b 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -4,8 +4,8 @@ Custom element classes related to the styles part """ -from ..shared import OxmlBaseElement, qn -from ..ns import nsmap +from ..shared import OxmlBaseElement +from ..ns import nsmap, qn class CT_Style(OxmlBaseElement): diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index a5fb024fc..98e7cad7c 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -4,9 +4,9 @@ Custom element classes for shape-related elements like ```` """ -from .shared import OxmlBaseElement, OxmlElement, qn +from .shared import OxmlBaseElement, OxmlElement from ..shared import Emu -from .ns import nsmap, nspfxmap +from .ns import nsmap, nspfxmap, qn class CT_Blip(OxmlBaseElement): diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index 222b278b7..5f765fe09 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -12,7 +12,7 @@ from . import oxml_parser from .exceptions import ValidationError -from .ns import NamespacePrefixedTag, nsmap +from .ns import NamespacePrefixedTag, nsmap, qn # =========================================================================== @@ -42,17 +42,6 @@ def oxml_fromstring(text): return etree.fromstring(text, oxml_parser) -def qn(tag): - """ - Stands for "qualified name", a utility function to turn a namespace - prefixed tag name into a Clark-notation qualified tag name for lxml. For - example, ``qn('p:cSld')`` returns ``'{http://schemas.../main}cSld'``. - """ - prefix, tagroot = tag.split(':') - uri = nsmap[prefix] - return '{%s}%s' % (uri, tagroot) - - def serialize_for_reading(element): """ Serialize *element* to human-readable XML suitable for tests. No XML diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 866f354e2..1587c5721 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -6,10 +6,9 @@ from __future__ import absolute_import, print_function, unicode_literals -from docx.oxml.shared import OxmlBaseElement, OxmlElement, qn - from .exceptions import ValidationError -from .shared import CT_String +from .ns import qn +from .shared import CT_String, OxmlBaseElement, OxmlElement from .text import CT_P diff --git a/docx/oxml/text.py b/docx/oxml/text.py index c83f2248e..79ea60354 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -6,11 +6,9 @@ """ from ..enum.text import WD_UNDERLINE -from .ns import nsdecls +from .ns import nsdecls, qn from .parts.numbering import CT_NumPr -from .shared import ( - CT_String, OxmlBaseElement, OxmlElement, oxml_fromstring, qn -) +from .shared import CT_String, OxmlBaseElement, OxmlElement, oxml_fromstring class CT_Br(OxmlBaseElement): diff --git a/features/steps/text.py b/features/steps/text.py index 134a9f53e..7c46a2ad0 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -10,7 +10,7 @@ from docx import Document from docx.enum.text import WD_BREAK, WD_UNDERLINE -from docx.oxml.shared import qn +from docx.oxml.ns import qn from helpers import test_docx, test_text From 769d2bb89f29c51f14c1ba333fe331856c87ad29 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 03:21:41 -0700 Subject: [PATCH 029/809] oxml: extract parse_xml() to pptx.oxml.__init__ Was `oxml_fromstring()`. Also renamed separate function of same name in pptx.opc.oxml having non-intersecting scope. --- docx/opc/oxml.py | 12 ++++++------ docx/opc/pkgreader.py | 6 +++--- docx/oxml/__init__.py | 11 +++++++++++ docx/oxml/shared.py | 7 ------- docx/oxml/text.py | 7 ++++--- docx/parts/document.py | 4 ++-- docx/parts/numbering.py | 4 ++-- docx/parts/styles.py | 4 ++-- tests/opc/test_pkgreader.py | 11 +++++------ tests/opc/unitdata/rels.py | 4 ++-- tests/parts/test_document.py | 11 +++++------ tests/parts/test_numbering.py | 18 +++++++++--------- tests/parts/test_styles.py | 18 +++++++++--------- tests/unitdata.py | 4 ++-- 14 files changed, 62 insertions(+), 59 deletions(-) diff --git a/docx/opc/oxml.py b/docx/opc/oxml.py index 220bcd156..6757daa79 100644 --- a/docx/opc/oxml.py +++ b/docx/opc/oxml.py @@ -30,7 +30,7 @@ # functions # =========================================================================== -def oxml_fromstring(text): +def parse_xml(text): """ ``etree.fromstring()`` replacement that uses oxml parser """ @@ -112,7 +112,7 @@ def new(ext, content_type): values. """ xml = '' % nsmap['ct'] - default = oxml_fromstring(xml) + default = parse_xml(xml) default.set('Extension', ext) default.set('ContentType', content_type) return default @@ -138,7 +138,7 @@ def new(partname, content_type): values. """ xml = '' % nsmap['ct'] - override = oxml_fromstring(xml) + override = parse_xml(xml) override.set('PartName', partname) override.set('ContentType', content_type) return override @@ -163,7 +163,7 @@ def new(rId, reltype, target, target_mode=RTM.INTERNAL): Return a new ```` element. """ xml = '' % nsmap['pr'] - relationship = oxml_fromstring(xml) + relationship = parse_xml(xml) relationship.set('Id', rId) relationship.set('Type', reltype) relationship.set('Target', target) @@ -224,7 +224,7 @@ def new(): Return a new ```` element. """ xml = '' % nsmap['pr'] - relationships = oxml_fromstring(xml) + relationships = parse_xml(xml) return relationships @property @@ -274,7 +274,7 @@ def new(): Return a new ```` element. """ xml = '' % nsmap['ct'] - types = oxml_fromstring(xml) + types = parse_xml(xml) return types @property diff --git a/docx/opc/pkgreader.py b/docx/opc/pkgreader.py index 6f8bb028f..ae80b3586 100644 --- a/docx/opc/pkgreader.py +++ b/docx/opc/pkgreader.py @@ -8,7 +8,7 @@ from __future__ import absolute_import from .constants import RELATIONSHIP_TARGET_MODE as RTM -from .oxml import oxml_fromstring +from .oxml import parse_xml from .packuri import PACKAGE_URI, PackURI from .phys_pkg import PhysPkgReader from .shared import CaseInsensitiveDict @@ -141,7 +141,7 @@ def from_xml(content_types_xml): Return a new |_ContentTypeMap| instance populated with the contents of *content_types_xml*. """ - types_elm = oxml_fromstring(content_types_xml) + types_elm = parse_xml(content_types_xml) ct_map = _ContentTypeMap() for o in types_elm.overrides: ct_map._add_override(o.partname, o.content_type) @@ -292,7 +292,7 @@ def load_from_xml(baseURI, rels_item_xml): """ srels = _SerializedRelationships() if rels_item_xml is not None: - rels_elm = oxml_fromstring(rels_item_xml) + rels_elm = parse_xml(rels_item_xml) for rel_elm in rels_elm.Relationship_lst: srels._srels.append(_SerializedRelationship(baseURI, rel_elm)) return srels diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index a2992c351..f6d0fa13a 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -18,6 +18,17 @@ oxml_parser.set_element_class_lookup(element_class_lookup) +def parse_xml(xml): + """ + Return root lxml element obtained by parsing XML character string in + *xml*, which can be either a Python 2.x string or unicode. The custom + parser is used, so custom element classes are produced for elements in + *xml* that have them. + """ + root_element = etree.fromstring(xml, oxml_parser) + return root_element + + def register_custom_element_class(tag, cls): """ Register *cls* to be constructed when the oxml parser encounters an diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index 5f765fe09..d4608e98b 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -35,13 +35,6 @@ def OxmlElement(nsptag_str, attrs=None, nsmap=None): ) -def oxml_fromstring(text): - """ - ``etree.fromstring()`` replacement that uses oxml parser - """ - return etree.fromstring(text, oxml_parser) - - def serialize_for_reading(element): """ Serialize *element* to human-readable XML suitable for tests. No XML diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 79ea60354..6e08a1528 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -5,10 +5,11 @@ (CT_R). """ +from . import parse_xml from ..enum.text import WD_UNDERLINE from .ns import nsdecls, qn from .parts.numbering import CT_NumPr -from .shared import CT_String, OxmlBaseElement, OxmlElement, oxml_fromstring +from .shared import CT_String, OxmlBaseElement, OxmlElement class CT_Br(OxmlBaseElement): @@ -66,7 +67,7 @@ def new(): Return a new ```` element. """ xml = '' % nsdecls('w') - p = oxml_fromstring(xml) + p = parse_xml(xml) return p @property @@ -140,7 +141,7 @@ def new(): Return a new ```` element. """ xml = '' % nsdecls('w') - pPr = oxml_fromstring(xml) + pPr = parse_xml(xml) return pPr @property diff --git a/docx/parts/document.py b/docx/parts/document.py index c0a973c19..757a92e17 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -11,8 +11,8 @@ from ..opc.constants import RELATIONSHIP_TYPE as RT from ..opc.oxml import serialize_part_xml from ..opc.package import Part +from ..oxml import parse_xml from ..oxml.ns import nsmap -from ..oxml.shared import oxml_fromstring from ..shape import InlineShape from ..shared import lazyproperty, Parented from ..table import Table @@ -76,7 +76,7 @@ def inline_shapes(self): @classmethod def load(cls, partname, content_type, blob, package): - document_elm = oxml_fromstring(blob) + document_elm = parse_xml(blob) document_part = cls(partname, content_type, document_elm, package) return document_part diff --git a/docx/parts/numbering.py b/docx/parts/numbering.py index fcf3e3c32..1b8242cc9 100644 --- a/docx/parts/numbering.py +++ b/docx/parts/numbering.py @@ -9,7 +9,7 @@ ) from ..opc.package import Part -from ..oxml.shared import oxml_fromstring +from ..oxml import parse_xml from ..shared import lazyproperty @@ -29,7 +29,7 @@ def load(cls, partname, content_type, blob, package): Provides PartFactory interface for loading a numbering part from a WML package. """ - numbering_elm = oxml_fromstring(blob) + numbering_elm = parse_xml(blob) numbering_part = cls(partname, content_type, numbering_elm, package) return numbering_part diff --git a/docx/parts/styles.py b/docx/parts/styles.py index b63317b9e..f6e544aeb 100644 --- a/docx/parts/styles.py +++ b/docx/parts/styles.py @@ -9,7 +9,7 @@ ) from ..opc.package import Part -from ..oxml.shared import oxml_fromstring +from ..oxml import parse_xml from ..shared import lazyproperty @@ -29,7 +29,7 @@ def load(cls, partname, content_type, blob, package): Provides PartFactory interface for loading a styles part from a WML package. """ - styles_elm = oxml_fromstring(blob) + styles_elm = parse_xml(blob) styles_part = cls(partname, content_type, styles_elm, package) return styles_part diff --git a/tests/opc/test_pkgreader.py b/tests/opc/test_pkgreader.py index 2f5a57928..b9947b496 100644 --- a/tests/opc/test_pkgreader.py +++ b/tests/opc/test_pkgreader.py @@ -435,8 +435,7 @@ def it_raises_on_target_partname_when_external(self): class Describe_SerializedRelationships(object): - def it_can_load_from_xml( - self, oxml_fromstring_, _SerializedRelationship_): + def it_can_load_from_xml(self, parse_xml_, _SerializedRelationship_): # mockery ---------------------- baseURI, rels_item_xml, rel_elm_1, rel_elm_2 = ( Mock(name='baseURI'), Mock(name='rels_item_xml'), @@ -445,7 +444,7 @@ def it_can_load_from_xml( rels_elm = Mock( name='rels_elm', Relationship_lst=[rel_elm_1, rel_elm_2] ) - oxml_fromstring_.return_value = rels_elm + parse_xml_.return_value = rels_elm # exercise --------------------- srels = _SerializedRelationships.load_from_xml( baseURI, rels_item_xml) @@ -454,7 +453,7 @@ def it_can_load_from_xml( call(baseURI, rel_elm_1), call(baseURI, rel_elm_2), ] - oxml_fromstring_.assert_called_once_with(rels_item_xml) + parse_xml_.assert_called_once_with(rels_item_xml) assert _SerializedRelationship_.call_args_list == expected_calls assert isinstance(srels, _SerializedRelationships) @@ -470,8 +469,8 @@ def it_should_be_iterable(self): # fixtures --------------------------------------------- @pytest.fixture - def oxml_fromstring_(self, request): - return function_mock(request, 'docx.opc.pkgreader.oxml_fromstring') + def parse_xml_(self, request): + return function_mock(request, 'docx.opc.pkgreader.parse_xml') @pytest.fixture def _SerializedRelationship_(self, request): diff --git a/tests/opc/unitdata/rels.py b/tests/opc/unitdata/rels.py index 106288938..f55f4c5f9 100644 --- a/tests/opc/unitdata/rels.py +++ b/tests/opc/unitdata/rels.py @@ -10,7 +10,7 @@ from docx.opc.package import Relationships from docx.opc.constants import NAMESPACE as NS -from docx.opc.oxml import oxml_fromstring +from docx.opc.oxml import parse_xml class BaseBuilder(object): @@ -20,7 +20,7 @@ class BaseBuilder(object): @property def element(self): """Return element based on XML generated by builder""" - return oxml_fromstring(self.xml) + return parse_xml(self.xml) def with_indent(self, indent): """Add integer *indent* spaces at beginning of element XML""" diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index c5cf5a36b..ee94ff8e6 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -52,19 +52,18 @@ def it_is_used_by_PartFactory_to_construct_main_document_part( ) assert part is document_part_ - def it_can_be_constructed_by_opc_part_factory( - self, oxml_fromstring_, init): + def it_can_be_constructed_by_opc_part_factory(self, parse_xml_, init): # mockery ---------------------- partname, content_type, blob, document_elm, package = ( Mock(name='partname'), Mock(name='content_type'), Mock(name='blob'), Mock(name='document_elm'), Mock(name='package') ) - oxml_fromstring_.return_value = document_elm + parse_xml_.return_value = document_elm # exercise --------------------- doc = DocumentPart.load(partname, content_type, blob, package) # verify ----------------------- - oxml_fromstring_.assert_called_once_with(blob) + parse_xml_.assert_called_once_with(blob) init.assert_called_once_with( partname, content_type, document_elm, package ) @@ -257,8 +256,8 @@ def next_id_fixture(self, request): return document, expected_id @pytest.fixture - def oxml_fromstring_(self, request): - return function_mock(request, 'docx.parts.document.oxml_fromstring') + def parse_xml_(self, request): + return function_mock(request, 'docx.parts.document.parse_xml') @pytest.fixture def p_(self, request): diff --git a/tests/parts/test_numbering.py b/tests/parts/test_numbering.py index 94fb5c849..c1ed98d31 100644 --- a/tests/parts/test_numbering.py +++ b/tests/parts/test_numbering.py @@ -39,14 +39,14 @@ def it_is_used_by_PartFactory_to_construct_numbering_part( assert part is numbering_part_ def it_can_be_constructed_by_opc_part_factory(self, construct_fixture): - (partname_, content_type_, blob_, package_, oxml_fromstring_, - init__, numbering_elm_) = construct_fixture + (partname_, content_type_, blob_, package_, parse_xml_, init__, + numbering_elm_) = construct_fixture # exercise --------------------- numbering_part = NumberingPart.load( partname_, content_type_, blob_, package_ ) # verify ----------------------- - oxml_fromstring_.assert_called_once_with(blob_) + parse_xml_.assert_called_once_with(blob_) init__.assert_called_once_with( partname_, content_type_, numbering_elm_, package_ ) @@ -68,11 +68,11 @@ def blob_(self, request): @pytest.fixture def construct_fixture( - self, partname_, content_type_, blob_, package_, - oxml_fromstring_, init__, numbering_elm_): + self, partname_, content_type_, blob_, package_, parse_xml_, + init__, numbering_elm_): return ( - partname_, content_type_, blob_, package_, oxml_fromstring_, - init__, numbering_elm_ + partname_, content_type_, blob_, package_, parse_xml_, init__, + numbering_elm_ ) @pytest.fixture @@ -126,9 +126,9 @@ def numbering_part_load_(self, request): return method_mock(request, NumberingPart, 'load') @pytest.fixture - def oxml_fromstring_(self, request, numbering_elm_): + def parse_xml_(self, request, numbering_elm_): return function_mock( - request, 'docx.parts.numbering.oxml_fromstring', + request, 'docx.parts.numbering.parse_xml', return_value=numbering_elm_ ) diff --git a/tests/parts/test_styles.py b/tests/parts/test_styles.py index 226fb2bda..85db435f9 100644 --- a/tests/parts/test_styles.py +++ b/tests/parts/test_styles.py @@ -39,14 +39,14 @@ def it_is_used_by_PartFactory_to_construct_styles_part( assert part is styles_part_ def it_can_be_constructed_by_opc_part_factory(self, construct_fixture): - (partname_, content_type_, blob_, package_, oxml_fromstring_, - init__, styles_elm_) = construct_fixture + (partname_, content_type_, blob_, package_, parse_xml_, init__, + styles_elm_) = construct_fixture # exercise --------------------- styles_part = StylesPart.load( partname_, content_type_, blob_, package_ ) # verify ----------------------- - oxml_fromstring_.assert_called_once_with(blob_) + parse_xml_.assert_called_once_with(blob_) init__.assert_called_once_with( partname_, content_type_, styles_elm_, package_ ) @@ -66,11 +66,11 @@ def blob_(self, request): @pytest.fixture def construct_fixture( - self, partname_, content_type_, blob_, package_, - oxml_fromstring_, init__, styles_elm_): + self, partname_, content_type_, blob_, package_, parse_xml_, + init__, styles_elm_): return ( - partname_, content_type_, blob_, package_, oxml_fromstring_, - init__, styles_elm_ + partname_, content_type_, blob_, package_, parse_xml_, init__, + styles_elm_ ) @pytest.fixture @@ -91,9 +91,9 @@ def load_fixture( ) @pytest.fixture - def oxml_fromstring_(self, request, styles_elm_): + def parse_xml_(self, request, styles_elm_): return function_mock( - request, 'docx.parts.styles.oxml_fromstring', + request, 'docx.parts.styles.parse_xml', return_value=styles_elm_ ) diff --git a/tests/unitdata.py b/tests/unitdata.py index 924bdf829..208be48de 100644 --- a/tests/unitdata.py +++ b/tests/unitdata.py @@ -6,8 +6,8 @@ from __future__ import absolute_import, print_function, unicode_literals +from docx.oxml import parse_xml from docx.oxml.ns import nsdecls -from docx.oxml.shared import oxml_fromstring class BaseBuilder(object): @@ -56,7 +56,7 @@ def element(self): """ Element parsed from XML generated by builder in current state """ - elm = oxml_fromstring(self.xml()) + elm = parse_xml(self.xml()) return elm def with_child(self, child_bldr): From 38f18c7004d36eb275891e50807cb1ba5aeed5f1 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 03:24:19 -0700 Subject: [PATCH 030/809] oxml: remove dead _SubElement() --- docx/oxml/shared.py | 6 +----- tests/test_shape.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index d4608e98b..62a517441 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -12,7 +12,7 @@ from . import oxml_parser from .exceptions import ValidationError -from .ns import NamespacePrefixedTag, nsmap, qn +from .ns import NamespacePrefixedTag, qn # =========================================================================== @@ -44,10 +44,6 @@ def serialize_for_reading(element): return XmlString(xml) -def _SubElement(parent, tag): - return etree.SubElement(parent, qn(tag), nsmap=nsmap) - - class XmlString(str): """ Provides string comparison override suitable for serialized XML that is diff --git a/tests/test_shape.py b/tests/test_shape.py index ca66c6a10..254c23b5d 100644 --- a/tests/test_shape.py +++ b/tests/test_shape.py @@ -9,7 +9,7 @@ import pytest from docx.enum.shape import WD_INLINE_SHAPE -from docx.oxml.shared import nsmap +from docx.oxml.ns import nsmap from docx.parts.image import ImagePart from docx.shape import InlineShape from docx.shared import Length From 75c0302e3ff8efd211f7af1fec0a9e837f135ff4 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 03:45:12 -0700 Subject: [PATCH 031/809] oxml: extract OxmlElement() to pptx.oxml --- docx/oxml/__init__.py | 17 ++++++++++++++++- docx/oxml/parts/numbering.py | 3 ++- docx/oxml/shape.py | 3 ++- docx/oxml/shared.py | 19 ++----------------- docx/oxml/table.py | 3 ++- docx/oxml/text.py | 4 ++-- docx/package.py | 2 +- 7 files changed, 27 insertions(+), 24 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index f6d0fa13a..6ae46676d 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -9,7 +9,7 @@ from lxml import etree -from .ns import nsmap +from .ns import NamespacePrefixedTag, nsmap # configure XML parser @@ -40,6 +40,21 @@ def register_custom_element_class(tag, cls): namespace[tagroot] = cls +def OxmlElement(nsptag_str, attrs=None, nsmap=None): + """ + Return a 'loose' lxml element having the tag specified by *nsptag_str*. + *nsptag_str* must contain the standard namespace prefix, e.g. 'a:tbl'. + The resulting element is an instance of the custom element class for this + tag name if one is defined. A dictionary of attribute values may be + provided as *attrs*; they are set if present. + """ + nsptag = NamespacePrefixedTag(nsptag_str) + _nsmap = nsptag.nsmap if nsmap is None else nsmap + return oxml_parser.makeelement( + nsptag.clark_name, attrib=attrs, nsmap=_nsmap + ) + + # =========================================================================== # custom element class mappings # =========================================================================== diff --git a/docx/oxml/parts/numbering.py b/docx/oxml/parts/numbering.py index c67d97274..0a33e4c32 100644 --- a/docx/oxml/parts/numbering.py +++ b/docx/oxml/parts/numbering.py @@ -4,7 +4,8 @@ Custom element classes related to the numbering part """ -from ..shared import CT_DecimalNumber, OxmlBaseElement, OxmlElement +from .. import OxmlElement +from ..shared import CT_DecimalNumber, OxmlBaseElement from ..ns import nsmap, qn diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index 98e7cad7c..7b433790a 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -4,7 +4,8 @@ Custom element classes for shape-related elements like ```` """ -from .shared import OxmlBaseElement, OxmlElement +from . import OxmlElement +from .shared import OxmlBaseElement from ..shared import Emu from .ns import nsmap, nspfxmap, qn diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index 62a517441..11962a067 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -10,9 +10,9 @@ import re -from . import oxml_parser +from . import OxmlElement from .exceptions import ValidationError -from .ns import NamespacePrefixedTag, qn +from .ns import qn # =========================================================================== @@ -20,21 +20,6 @@ # =========================================================================== -def OxmlElement(nsptag_str, attrs=None, nsmap=None): - """ - Return a 'loose' lxml element having the tag specified by *nsptag_str*. - *nsptag_str* must contain the standard namespace prefix, e.g. 'a:tbl'. - The resulting element is an instance of the custom element class for this - tag name if one is defined. A dictionary of attribute values may be - provided as *attrs*; they are set if present. - """ - nsptag = NamespacePrefixedTag(nsptag_str) - _nsmap = nsptag.nsmap if nsmap is None else nsmap - return oxml_parser.makeelement( - nsptag.clark_name, attrib=attrs, nsmap=_nsmap - ) - - def serialize_for_reading(element): """ Serialize *element* to human-readable XML suitable for tests. No XML diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 1587c5721..973e82356 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -6,9 +6,10 @@ from __future__ import absolute_import, print_function, unicode_literals +from . import OxmlElement from .exceptions import ValidationError from .ns import qn -from .shared import CT_String, OxmlBaseElement, OxmlElement +from .shared import CT_String, OxmlBaseElement from .text import CT_P diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 6e08a1528..9a37d440c 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -5,11 +5,11 @@ (CT_R). """ -from . import parse_xml +from . import parse_xml, OxmlElement from ..enum.text import WD_UNDERLINE from .ns import nsdecls, qn from .parts.numbering import CT_NumPr -from .shared import CT_String, OxmlBaseElement, OxmlElement +from .shared import CT_String, OxmlBaseElement class CT_Br(OxmlBaseElement): diff --git a/docx/package.py b/docx/package.py index 05eef1d77..4c9a6f6a1 100644 --- a/docx/package.py +++ b/docx/package.py @@ -110,6 +110,6 @@ 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): - if not n in used_numbers: + if n not in used_numbers: return image_partname(n) return image_partname(len(self)+1) From d892000311b12c02e2ac882ab489c9f90cf649ef Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 11:32:36 -0700 Subject: [PATCH 032/809] oxml: extract XmlString to oxml.xmlchemy serialize_for_reading() had to go at the same time to avoid a circular import dependency. --- docx/oxml/shared.py | 86 +---------------------------------- docx/oxml/xmlchemy.py | 87 +++++++++++++++++++++++++++++++++++ tests/oxml/test_shared.py | 64 -------------------------- tests/oxml/test_xmlchemy.py | 90 +++++++++++++++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 149 deletions(-) create mode 100644 docx/oxml/xmlchemy.py create mode 100644 tests/oxml/test_xmlchemy.py diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index 11962a067..baf5820a4 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -8,96 +8,12 @@ from lxml import etree -import re - from . import OxmlElement from .exceptions import ValidationError from .ns import qn +from .xmlchemy import serialize_for_reading -# =========================================================================== -# utility functions -# =========================================================================== - - -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) - return XmlString(xml) - - -class XmlString(str): - """ - Provides string comparison override suitable for serialized XML that is - useful for tests. - """ - - # ' text' - # | | || | - # +----------+------------------------------------------++-----------+ - # front attrs | text - # close - - _xml_elm_line_patt = re.compile( - '( *)([^<]*)?' - ) - - def __eq__(self, other): - lines = self.splitlines() - lines_other = other.splitlines() - if len(lines) != len(lines_other): - return False - for line, line_other in zip(lines, lines_other): - if not self._eq_elm_strs(line, line_other): - return False - return True - - def __ne__(self, other): - return not self.__eq__(other) - - def _attr_seq(self, attrs): - """ - Return a sequence of attribute strings parsed from *attrs*. Each - attribute string is stripped of whitespace on both ends. - """ - attrs = attrs.strip() - attr_lst = attrs.split() - return sorted(attr_lst) - - def _eq_elm_strs(self, line, line_2): - """ - Return True if the element in *line_2* is XML equivalent to the - element in *line*. - """ - front, attrs, close, text = self._parse_line(line) - front_2, attrs_2, close_2, text_2 = self._parse_line(line_2) - if front != front_2: - return False - if self._attr_seq(attrs) != self._attr_seq(attrs_2): - return False - if close != close_2: - return False - if text != text_2: - return False - return True - - def _parse_line(self, line): - """ - Return front, attrs, close, text 4-tuple result of parsing XML element - string *line*. - """ - match = self._xml_elm_line_patt.match(line) - front, attrs, close, text = [match.group(n) for n in range(1, 5)] - return front, attrs, close, text - - -# =========================================================================== -# shared custom element classes -# =========================================================================== - class OxmlBaseElement(etree.ElementBase): """ Base class for all custom element classes, to add standardized behavior diff --git a/docx/oxml/xmlchemy.py b/docx/oxml/xmlchemy.py new file mode 100644 index 000000000..d7a25e9ab --- /dev/null +++ b/docx/oxml/xmlchemy.py @@ -0,0 +1,87 @@ +# encoding: utf-8 + +""" +Provides a wrapper around lxml that enables declarative definition of custom +element classes. +""" + +from __future__ import absolute_import + +from lxml import etree + +import re + + +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) + return XmlString(xml) + + +class XmlString(str): + """ + Provides string comparison override suitable for serialized XML that is + useful for tests. + """ + + # ' text' + # | | || | + # +----------+------------------------------------------++-----------+ + # front attrs | text + # close + + _xml_elm_line_patt = re.compile( + '( *)([^<]*)?$' + ) + + def __eq__(self, other): + lines = self.splitlines() + lines_other = other.splitlines() + if len(lines) != len(lines_other): + return False + for line, line_other in zip(lines, lines_other): + if not self._eq_elm_strs(line, line_other): + return False + return True + + def __ne__(self, other): + return not self.__eq__(other) + + def _attr_seq(self, attrs): + """ + Return a sequence of attribute strings parsed from *attrs*. Each + attribute string is stripped of whitespace on both ends. + """ + attrs = attrs.strip() + attr_lst = attrs.split() + return sorted(attr_lst) + + def _eq_elm_strs(self, line, line_2): + """ + Return True if the element in *line_2* is XML equivalent to the + element in *line*. + """ + front, attrs, close, text = self._parse_line(line) + front_2, attrs_2, close_2, text_2 = self._parse_line(line_2) + if front != front_2: + return False + if self._attr_seq(attrs) != self._attr_seq(attrs_2): + return False + if close != close_2: + return False + if text != text_2: + return False + return True + + @classmethod + def _parse_line(cls, line): + """ + Return front, attrs, close, text 4-tuple result of parsing XML element + string *line*. + """ + match = cls._xml_elm_line_patt.match(line) + front, attrs, close, text = [match.group(n) for n in range(1, 5)] + return front, attrs, close, text diff --git a/tests/oxml/test_shared.py b/tests/oxml/test_shared.py index f3394c078..e69de29bb 100644 --- a/tests/oxml/test_shared.py +++ b/tests/oxml/test_shared.py @@ -1,64 +0,0 @@ -# encoding: utf-8 - -""" -Test suite for docx.oxml.shared -""" - -from __future__ import ( - absolute_import, division, print_function, unicode_literals -) - -import pytest - -from docx.oxml.shared import XmlString - - -class DescribeXmlString(object): - - def it_knows_if_two_xml_lines_are_equivalent(self, xml_line_case): - line, other, differs = xml_line_case - xml = XmlString(line) - assert xml == other - assert xml != differs - - # fixtures --------------------------------------------- - - @pytest.fixture(params=[ - 'simple_elm', 'nsp_tagname', 'indent', 'attrs', 'nsdecl_order', - 'closing_elm', - ]) - def xml_line_case(self, request): - cases = { - 'simple_elm': ( - '', - '', - '', - ), - 'nsp_tagname': ( - '', - '', - '', - ), - 'indent': ( - ' ', - ' ', - '', - ), - 'attrs': ( - ' ', - ' ', - ' ', - ), - 'nsdecl_order': ( - ' ', - ' ', - ' ', - ), - 'closing_elm': ( - '', - '', - '', - ), - } - line, other, differs = cases[request.param] - return line, other, differs diff --git a/tests/oxml/test_xmlchemy.py b/tests/oxml/test_xmlchemy.py new file mode 100644 index 000000000..7f58203dc --- /dev/null +++ b/tests/oxml/test_xmlchemy.py @@ -0,0 +1,90 @@ +# encoding: utf-8 + +""" +Test suite for docx.oxml.xmlchemy +""" + +from __future__ import absolute_import, print_function, unicode_literals + +import pytest + +from docx.oxml.xmlchemy import XmlString + + +class DescribeXmlString(object): + + def it_parses_a_line_to_help_compare(self, parse_fixture): + """ + This internal function is important to test separately because if it + doesn't parse a line properly, false equality can result. + """ + line, expected_front, expected_attrs = parse_fixture[:3] + expected_close, expected_text = parse_fixture[3:] + front, attrs, close, text = XmlString._parse_line(line) + # print("'%s' '%s' '%s' %s" % ( + # front, attrs, close, ('%s' % text) if text else text)) + assert front == expected_front + assert attrs == expected_attrs + assert close == expected_close + assert text == expected_text + + def it_knows_if_two_xml_lines_are_equivalent(self, xml_line_case): + line, other, differs = xml_line_case + xml = XmlString(line) + assert xml == other + assert xml != differs + + # fixtures --------------------------------------------- + + @pytest.fixture(params=[ + ('text', '', 'text'), + ('', '', None), + ('', '', None), + ('t', '', 't'), + ('2013-12-23T23:15:00Z', '', '2013-12-23T23:15:00Z'), + ]) + def parse_fixture(self, request): + line, front, attrs, close, text = request.param + return line, front, attrs, close, text + + @pytest.fixture(params=[ + 'simple_elm', 'nsp_tagname', 'indent', 'attrs', 'nsdecl_order', + 'closing_elm', + ]) + def xml_line_case(self, request): + cases = { + 'simple_elm': ( + '', + '', + '', + ), + 'nsp_tagname': ( + '', + '', + '', + ), + 'indent': ( + ' ', + ' ', + '', + ), + 'attrs': ( + ' ', + ' ', + ' ', + ), + 'nsdecl_order': ( + ' ', + ' ', + ' ', + ), + 'closing_elm': ( + '', + '', + '', + ), + } + line, other, differs = cases[request.param] + return line, other, differs From 8ae8ba5fdf36fb73bd682ae6134fb7f8897eb5b0 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 12:27:29 -0700 Subject: [PATCH 033/809] oxml: add unit tests for serialize_for_reading() --- docx/compat.py | 4 ++++ docx/oxml/xmlchemy.py | 4 +++- tests/oxml/test_xmlchemy.py | 38 ++++++++++++++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docx/compat.py b/docx/compat.py index ba41d1334..dc9e20e39 100644 --- a/docx/compat.py +++ b/docx/compat.py @@ -24,6 +24,8 @@ def is_string(obj): """ return isinstance(obj, str) + Unicode = str + # =========================================================================== # Python 2 versions # =========================================================================== @@ -37,3 +39,5 @@ def is_string(obj): Return True if *obj* is a string, False otherwise. """ return isinstance(obj, basestring) + + Unicode = unicode diff --git a/docx/oxml/xmlchemy.py b/docx/oxml/xmlchemy.py index d7a25e9ab..0173121be 100644 --- a/docx/oxml/xmlchemy.py +++ b/docx/oxml/xmlchemy.py @@ -11,6 +11,8 @@ import re +from ..compat import Unicode + def serialize_for_reading(element): """ @@ -21,7 +23,7 @@ def serialize_for_reading(element): return XmlString(xml) -class XmlString(str): +class XmlString(Unicode): """ Provides string comparison override suitable for serialized XML that is useful for tests. diff --git a/tests/oxml/test_xmlchemy.py b/tests/oxml/test_xmlchemy.py index 7f58203dc..8fd4344c7 100644 --- a/tests/oxml/test_xmlchemy.py +++ b/tests/oxml/test_xmlchemy.py @@ -8,7 +8,43 @@ import pytest -from docx.oxml.xmlchemy import XmlString +from docx.compat import Unicode +from docx.oxml import parse_xml +from docx.oxml.xmlchemy import serialize_for_reading, XmlString + + +class DescribeSerializeForReading(object): + + def it_pretty_prints_an_lxml_element(self, pretty_fixture): + element, expected_xml_text = pretty_fixture + xml_text = serialize_for_reading(element) + assert xml_text == expected_xml_text + + def it_returns_unicode_text(self, type_fixture): + element = type_fixture + xml_text = serialize_for_reading(element) + assert isinstance(xml_text, Unicode) + + # fixtures --------------------------------------------- + + @pytest.fixture + def pretty_fixture(self, element): + expected_xml_text = ( + '\n' + ' text\n' + '\n' + ) + return element, expected_xml_text + + @pytest.fixture + def type_fixture(self, element): + return element + + # fixture components ----------------------------------- + + @pytest.fixture + def element(self): + return parse_xml('text') class DescribeXmlString(object): From 44cc3301505f7b823713e7a75f570716886622f7 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 13:16:38 -0700 Subject: [PATCH 034/809] oxml: add unit tests for oxml_parser --- tests/oxml/test__init__.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/oxml/test__init__.py diff --git a/tests/oxml/test__init__.py b/tests/oxml/test__init__.py new file mode 100644 index 000000000..1d1311d2c --- /dev/null +++ b/tests/oxml/test__init__.py @@ -0,0 +1,34 @@ +# encoding: utf-8 + +""" +Test suite for pptx.oxml.__init__.py module, primarily XML parser-related. +""" + +from __future__ import print_function, unicode_literals + +import pytest + +from lxml import etree + +from docx.oxml import oxml_parser + + +class DescribeOxmlParser(object): + + def it_strips_whitespace_between_elements(self, whitespace_fixture): + pretty_xml_text, stripped_xml_text = whitespace_fixture + element = etree.fromstring(pretty_xml_text, oxml_parser) + xml_text = etree.tostring(element, encoding='unicode') + assert xml_text == stripped_xml_text + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def whitespace_fixture(self): + pretty_xml_text = ( + '\n' + ' text\n' + '\n' + ) + stripped_xml_text = 'text' + return pretty_xml_text, stripped_xml_text From eb3641156926b6ed37d4f94dfe9db3d400c8e3dd Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 13:18:10 -0700 Subject: [PATCH 035/809] oxml: add unit tests for parse_xml() --- tests/oxml/test__init__.py | 47 +++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/oxml/test__init__.py b/tests/oxml/test__init__.py index 1d1311d2c..9c722fe55 100644 --- a/tests/oxml/test__init__.py +++ b/tests/oxml/test__init__.py @@ -10,7 +10,8 @@ from lxml import etree -from docx.oxml import oxml_parser +from docx.oxml import oxml_parser, parse_xml, register_custom_element_class +from docx.oxml.shared import OxmlBaseElement class DescribeOxmlParser(object): @@ -32,3 +33,47 @@ def whitespace_fixture(self): ) stripped_xml_text = 'text' return pretty_xml_text, stripped_xml_text + + +class DescribeParseXml(object): + + def it_accepts_bytes_and_assumes_utf8_encoding(self, xml_bytes): + parse_xml(xml_bytes) + + def it_accepts_unicode_providing_there_is_no_encoding_declaration(self): + non_enc_decl = '' + enc_decl = '' + xml_body = 'føøbår' + # unicode body by itself doesn't raise + parse_xml(xml_body) + # adding XML decl without encoding attr doesn't raise either + xml_text = '%s\n%s' % (non_enc_decl, xml_body) + parse_xml(xml_text) + # but adding encoding in the declaration raises ValueError + xml_text = '%s\n%s' % (enc_decl, xml_body) + with pytest.raises(ValueError): + parse_xml(xml_text) + + def it_uses_registered_element_classes(self, xml_bytes): + register_custom_element_class('a:foo', CustElmCls) + element = parse_xml(xml_bytes) + assert isinstance(element, CustElmCls) + + # fixture components --------------------------------------------- + + @pytest.fixture + def xml_bytes(self): + return ( + '\n' + ' foøbår\n' + '\n' + ).encode('utf-8') + + +# =========================================================================== +# static fixture +# =========================================================================== + +class CustElmCls(OxmlBaseElement): + pass From 97f27d06b5f6978f102f6e8c49918a358ffd1caf Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 13:27:49 -0700 Subject: [PATCH 036/809] oxml: add unit tests for register_element_cls() renamed from register_custom_element_class() --- docx/oxml/__init__.py | 108 ++++++++++++++++++------------------- tests/oxml/test__init__.py | 26 ++++++++- 2 files changed, 78 insertions(+), 56 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 6ae46676d..7a1118c34 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -29,7 +29,7 @@ def parse_xml(xml): return root_element -def register_custom_element_class(tag, cls): +def register_element_cls(tag, cls): """ Register *cls* to be constructed when the oxml parser encounters an element with matching *tag*. *tag* is a string of the form @@ -65,70 +65,70 @@ def OxmlElement(nsptag_str, attrs=None, nsmap=None): CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, CT_GraphicalObjectData, CT_Inline, CT_Picture, CT_PositiveSize2D ) -register_custom_element_class('a:blip', CT_Blip) -register_custom_element_class('a:graphic', CT_GraphicalObject) -register_custom_element_class('a:graphicData', CT_GraphicalObjectData) -register_custom_element_class('pic:blipFill', CT_BlipFillProperties) -register_custom_element_class('pic:pic', CT_Picture) -register_custom_element_class('wp:extent', CT_PositiveSize2D) -register_custom_element_class('wp:inline', CT_Inline) +register_element_cls('a:blip', CT_Blip) +register_element_cls('a:graphic', CT_GraphicalObject) +register_element_cls('a:graphicData', CT_GraphicalObjectData) +register_element_cls('pic:blipFill', CT_BlipFillProperties) +register_element_cls('pic:pic', CT_Picture) +register_element_cls('wp:extent', CT_PositiveSize2D) +register_element_cls('wp:inline', CT_Inline) from docx.oxml.parts.document import CT_Body, CT_Document -register_custom_element_class('w:body', CT_Body) -register_custom_element_class('w:document', CT_Document) +register_element_cls('w:body', CT_Body) +register_element_cls('w:document', CT_Document) from docx.oxml.parts.numbering import ( CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr ) -register_custom_element_class('w:abstractNumId', CT_DecimalNumber) -register_custom_element_class('w:ilvl', CT_DecimalNumber) -register_custom_element_class('w:lvlOverride', CT_NumLvl) -register_custom_element_class('w:num', CT_Num) -register_custom_element_class('w:numId', CT_DecimalNumber) -register_custom_element_class('w:numPr', CT_NumPr) -register_custom_element_class('w:numbering', CT_Numbering) +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) from docx.oxml.parts.styles import CT_Style, CT_Styles -register_custom_element_class('w:style', CT_Style) -register_custom_element_class('w:styles', CT_Styles) +register_element_cls('w:style', CT_Style) +register_element_cls('w:styles', CT_Styles) from docx.oxml.table import CT_Row, CT_Tbl, CT_TblGrid, CT_TblPr, CT_Tc -register_custom_element_class('w:tbl', CT_Tbl) -register_custom_element_class('w:tblGrid', CT_TblGrid) -register_custom_element_class('w:tblPr', CT_TblPr) -register_custom_element_class('w:tblStyle', CT_String) -register_custom_element_class('w:tc', CT_Tc) -register_custom_element_class('w:tr', CT_Row) +register_element_cls('w:tbl', CT_Tbl) +register_element_cls('w:tblGrid', CT_TblGrid) +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:tr', CT_Row) from docx.oxml.text import ( CT_Br, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text, CT_Underline ) -register_custom_element_class('w:b', CT_OnOff) -register_custom_element_class('w:bCs', CT_OnOff) -register_custom_element_class('w:br', CT_Br) -register_custom_element_class('w:caps', CT_OnOff) -register_custom_element_class('w:cs', CT_OnOff) -register_custom_element_class('w:dstrike', CT_OnOff) -register_custom_element_class('w:emboss', CT_OnOff) -register_custom_element_class('w:i', CT_OnOff) -register_custom_element_class('w:iCs', CT_OnOff) -register_custom_element_class('w:imprint', CT_OnOff) -register_custom_element_class('w:noProof', CT_OnOff) -register_custom_element_class('w:oMath', CT_OnOff) -register_custom_element_class('w:outline', CT_OnOff) -register_custom_element_class('w:p', CT_P) -register_custom_element_class('w:pPr', CT_PPr) -register_custom_element_class('w:pStyle', CT_String) -register_custom_element_class('w:r', CT_R) -register_custom_element_class('w:rPr', CT_RPr) -register_custom_element_class('w:rStyle', CT_String) -register_custom_element_class('w:rtl', CT_OnOff) -register_custom_element_class('w:shadow', CT_OnOff) -register_custom_element_class('w:smallCaps', CT_OnOff) -register_custom_element_class('w:snapToGrid', CT_OnOff) -register_custom_element_class('w:specVanish', CT_OnOff) -register_custom_element_class('w:strike', CT_OnOff) -register_custom_element_class('w:t', CT_Text) -register_custom_element_class('w:u', CT_Underline) -register_custom_element_class('w:vanish', CT_OnOff) -register_custom_element_class('w:webHidden', CT_OnOff) +register_element_cls('w:b', CT_OnOff) +register_element_cls('w:bCs', CT_OnOff) +register_element_cls('w:br', CT_Br) +register_element_cls('w:caps', CT_OnOff) +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: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:p', CT_P) +register_element_cls('w:pPr', CT_PPr) +register_element_cls('w:pStyle', CT_String) +register_element_cls('w:r', CT_R) +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:t', CT_Text) +register_element_cls('w:u', CT_Underline) +register_element_cls('w:vanish', CT_OnOff) +register_element_cls('w:webHidden', CT_OnOff) diff --git a/tests/oxml/test__init__.py b/tests/oxml/test__init__.py index 9c722fe55..b23a5fc28 100644 --- a/tests/oxml/test__init__.py +++ b/tests/oxml/test__init__.py @@ -10,7 +10,8 @@ from lxml import etree -from docx.oxml import oxml_parser, parse_xml, register_custom_element_class +from docx.oxml import oxml_parser, parse_xml, register_element_cls +from docx.oxml.ns import qn from docx.oxml.shared import OxmlBaseElement @@ -55,7 +56,7 @@ def it_accepts_unicode_providing_there_is_no_encoding_declaration(self): parse_xml(xml_text) def it_uses_registered_element_classes(self, xml_bytes): - register_custom_element_class('a:foo', CustElmCls) + register_element_cls('a:foo', CustElmCls) element = parse_xml(xml_bytes) assert isinstance(element, CustElmCls) @@ -71,6 +72,27 @@ def xml_bytes(self): ).encode('utf-8') +class DescribeRegisterElementCls(object): + + def it_determines_class_used_for_elements_with_matching_tagname( + self, xml_text): + register_element_cls('a:foo', CustElmCls) + foo = parse_xml(xml_text) + assert type(foo) is CustElmCls + assert type(foo.find(qn('a:bar'))) is etree._Element + + # fixture components --------------------------------------------- + + @pytest.fixture + def xml_text(self): + return ( + '\n' + ' foøbår\n' + '\n' + ) + + # =========================================================================== # static fixture # =========================================================================== From df464f4e8f019a39ee2d8cec4ffdac6a87659ffc Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 14:59:00 -0700 Subject: [PATCH 037/809] oxml: add unit tests for OxmlElement --- docx/oxml/__init__.py | 13 +++++++++---- docx/oxml/shape.py | 4 ++-- tests/oxml/test__init__.py | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 7a1118c34..7d6350929 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -40,18 +40,23 @@ def register_element_cls(tag, cls): namespace[tagroot] = cls -def OxmlElement(nsptag_str, attrs=None, nsmap=None): +def OxmlElement(nsptag_str, attrs=None, nsdecls=None): """ Return a 'loose' lxml element having the tag specified by *nsptag_str*. *nsptag_str* must contain the standard namespace prefix, e.g. 'a:tbl'. The resulting element is an instance of the custom element class for this tag name if one is defined. A dictionary of attribute values may be - provided as *attrs*; they are set if present. + provided as *attrs*; they are set if present. All namespaces defined in + the dict *nsdecls* are declared in the element using the key as the + prefix and the value as the namespace name. If *nsdecls* is not provided, + a single namespace declaration is added based on the prefix on + *nsptag_str*. """ nsptag = NamespacePrefixedTag(nsptag_str) - _nsmap = nsptag.nsmap if nsmap is None else nsmap + if nsdecls is None: + nsdecls = nsptag.nsmap return oxml_parser.makeelement( - nsptag.clark_name, attrib=attrs, nsmap=_nsmap + nsptag.clark_name, attrib=attrs, nsmap=nsdecls ) diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index 7b433790a..16bad25d0 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -102,7 +102,7 @@ def new(cls, cx, cy, shape_id, pic): name = 'Picture %d' % shape_id uri = nsmap['pic'] - inline = OxmlElement('wp:inline', nsmap=nspfxmap('wp', 'r')) + inline = OxmlElement('wp:inline', nsdecls=nspfxmap('wp', 'r')) inline.append(CT_PositiveSize2D.new('wp:extent', cx, cy)) inline.append(CT_NonVisualDrawingProps.new( 'wp:docPr', shape_id, name @@ -149,7 +149,7 @@ def new(cls, pic_id, filename, rId, cx, cy): contents required to define a viable picture element, based on the values passed as parameters. """ - pic = OxmlElement('pic:pic', nsmap=nspfxmap('pic', 'r')) + pic = OxmlElement('pic:pic', nsdecls=nspfxmap('pic', 'r')) pic.append(CT_PictureNonVisual.new(pic_id, filename)) pic.append(CT_BlipFillProperties.new(rId)) pic.append(CT_ShapeProperties.new(cx, cy)) diff --git a/tests/oxml/test__init__.py b/tests/oxml/test__init__.py index b23a5fc28..6db24d3ca 100644 --- a/tests/oxml/test__init__.py +++ b/tests/oxml/test__init__.py @@ -10,11 +10,42 @@ from lxml import etree -from docx.oxml import oxml_parser, parse_xml, register_element_cls +from docx.oxml import ( + OxmlElement, oxml_parser, parse_xml, register_element_cls +) from docx.oxml.ns import qn from docx.oxml.shared import OxmlBaseElement +class DescribeOxmlElement(object): + + def it_returns_an_lxml_element_with_matching_tag_name(self): + element = OxmlElement('a:foo') + assert isinstance(element, etree._Element) + assert element.tag == ( + '{http://schemas.openxmlformats.org/drawingml/2006/main}foo' + ) + + def it_adds_supplied_attributes(self): + element = OxmlElement('a:foo', {'a': 'b', 'c': 'd'}) + assert etree.tostring(element) == ( + '' + ) + + def it_adds_additional_namespace_declarations_when_supplied(self): + element = OxmlElement( + 'a:foo', nsdecls={ + 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main', + 'x': 'other' + } + ) + assert etree.tostring(element) == ( + '' + ) + + class DescribeOxmlParser(object): def it_strips_whitespace_between_elements(self, whitespace_fixture): From ddd5c3086168e37881c639462e2b433ae343d881 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 15:15:08 -0700 Subject: [PATCH 038/809] oxml: mv BaseOxmlElement to docx.oxml.xmlchemy was OxmlBaseElement --- docx/opc/oxml.py | 12 +++++----- docx/oxml/parts/document.py | 6 ++--- docx/oxml/parts/numbering.py | 11 +++++---- docx/oxml/parts/styles.py | 6 ++--- docx/oxml/shape.py | 34 ++++++++++++++-------------- docx/oxml/shared.py | 44 ++++-------------------------------- docx/oxml/table.py | 15 ++++++------ docx/oxml/text.py | 17 +++++++------- docx/oxml/xmlchemy.py | 35 ++++++++++++++++++++++++++++ tests/oxml/test__init__.py | 4 ++-- tests/oxml/test_shared.py | 0 tests/unitutil.py | 2 +- 12 files changed, 94 insertions(+), 92 deletions(-) delete mode 100644 tests/oxml/test_shared.py diff --git a/docx/opc/oxml.py b/docx/opc/oxml.py index 6757daa79..0c09312b5 100644 --- a/docx/opc/oxml.py +++ b/docx/opc/oxml.py @@ -69,7 +69,7 @@ def serialize_for_reading(element): # Custom element classes # =========================================================================== -class OxmlBaseElement(etree.ElementBase): +class BaseOxmlElement(etree.ElementBase): """ Base class for all custom element classes, to add standardized behavior to all classes in one place. @@ -84,7 +84,7 @@ def xml(self): return serialize_for_reading(self) -class CT_Default(OxmlBaseElement): +class CT_Default(BaseOxmlElement): """ ```` element, specifying the default content type to be applied to a part with the specified extension. @@ -118,7 +118,7 @@ def new(ext, content_type): return default -class CT_Override(OxmlBaseElement): +class CT_Override(BaseOxmlElement): """ ```` element, specifying the content type to be applied for a part with the specified partname. @@ -152,7 +152,7 @@ def partname(self): return self.get('PartName') -class CT_Relationship(OxmlBaseElement): +class CT_Relationship(BaseOxmlElement): """ ```` element, representing a single relationship from a source to a target part. @@ -205,7 +205,7 @@ def target_mode(self): return self.get('TargetMode', RTM.INTERNAL) -class CT_Relationships(OxmlBaseElement): +class CT_Relationships(BaseOxmlElement): """ ```` element, the root element in a .rels file. """ @@ -243,7 +243,7 @@ def xml(self): return serialize_part_xml(self) -class CT_Types(OxmlBaseElement): +class CT_Types(BaseOxmlElement): """ ```` element, the container element for Default and Override elements in [Content_Types].xml. diff --git a/docx/oxml/parts/document.py b/docx/oxml/parts/document.py index 4bb641bad..1773680f6 100644 --- a/docx/oxml/parts/document.py +++ b/docx/oxml/parts/document.py @@ -6,12 +6,12 @@ """ from ..ns import qn -from ..shared import OxmlBaseElement from ..table import CT_Tbl from ..text import CT_P +from ..xmlchemy import BaseOxmlElement -class CT_Document(OxmlBaseElement): +class CT_Document(BaseOxmlElement): """ ```` element, the root element of a document.xml file. """ @@ -20,7 +20,7 @@ def body(self): return self.find(qn('w:body')) -class CT_Body(OxmlBaseElement): +class CT_Body(BaseOxmlElement): """ ````, the container element for the main document story in ``document.xml``. diff --git a/docx/oxml/parts/numbering.py b/docx/oxml/parts/numbering.py index 0a33e4c32..04242f58e 100644 --- a/docx/oxml/parts/numbering.py +++ b/docx/oxml/parts/numbering.py @@ -5,11 +5,12 @@ """ from .. import OxmlElement -from ..shared import CT_DecimalNumber, OxmlBaseElement +from ..shared import CT_DecimalNumber from ..ns import nsmap, qn +from ..xmlchemy import BaseOxmlElement -class CT_Num(OxmlBaseElement): +class CT_Num(BaseOxmlElement): """ ```` element, which represents a concrete list definition instance, having a required child that references an @@ -48,7 +49,7 @@ def numId(self): return int(numId_str) -class CT_NumLvl(OxmlBaseElement): +class CT_NumLvl(BaseOxmlElement): """ ```` element, which identifies a level in a list definition to override with settings it contains. @@ -71,7 +72,7 @@ def new(cls, ilvl): return OxmlElement('w:lvlOverride', {qn('w:ilvl'): str(ilvl)}) -class CT_NumPr(OxmlBaseElement): +class CT_NumPr(BaseOxmlElement): """ A ```` element, a container for numbering properties applied to a paragraph. @@ -152,7 +153,7 @@ def _insert_numId(self, numId): ) -class CT_Numbering(OxmlBaseElement): +class CT_Numbering(BaseOxmlElement): """ ```` element, the root element of a numbering part, i.e. numbering.xml diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index 28235ef5b..f0072a307 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -4,11 +4,11 @@ Custom element classes related to the styles part """ -from ..shared import OxmlBaseElement from ..ns import nsmap, qn +from ..xmlchemy import BaseOxmlElement -class CT_Style(OxmlBaseElement): +class CT_Style(BaseOxmlElement): """ A ```` element, representing a style definition """ @@ -17,7 +17,7 @@ def pPr(self): return self.find(qn('w:pPr')) -class CT_Styles(OxmlBaseElement): +class CT_Styles(BaseOxmlElement): """ ```` element, the root element of a styles part, i.e. styles.xml diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index 16bad25d0..5c18694d5 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -5,12 +5,12 @@ """ from . import OxmlElement -from .shared import OxmlBaseElement from ..shared import Emu from .ns import nsmap, nspfxmap, qn +from .xmlchemy import BaseOxmlElement -class CT_Blip(OxmlBaseElement): +class CT_Blip(BaseOxmlElement): """ ```` element, specifies image source and adjustments such as alpha and tint. @@ -30,7 +30,7 @@ def new(cls, rId): return blip -class CT_BlipFillProperties(OxmlBaseElement): +class CT_BlipFillProperties(BaseOxmlElement): """ ```` element, specifies picture properties """ @@ -46,7 +46,7 @@ def new(cls, rId): return blipFill -class CT_GraphicalObject(OxmlBaseElement): +class CT_GraphicalObject(BaseOxmlElement): """ ```` element, container for a DrawingML object """ @@ -61,7 +61,7 @@ def new(cls, uri, pic): return graphic -class CT_GraphicalObjectData(OxmlBaseElement): +class CT_GraphicalObjectData(BaseOxmlElement): """ ```` element, container for the XML of a DrawingML object """ @@ -81,7 +81,7 @@ def uri(self): return self.get('uri') -class CT_Inline(OxmlBaseElement): +class CT_Inline(BaseOxmlElement): """ ```` element, container for an inline shape. """ @@ -111,7 +111,7 @@ def new(cls, cx, cy, shape_id, pic): return inline -class CT_NonVisualDrawingProps(OxmlBaseElement): +class CT_NonVisualDrawingProps(BaseOxmlElement): """ Used for ```` element, and perhaps others. Specifies the id and name of a DrawingML drawing. @@ -124,7 +124,7 @@ def new(cls, nsptagname_str, shape_id, name): return elt -class CT_NonVisualPictureProperties(OxmlBaseElement): +class CT_NonVisualPictureProperties(BaseOxmlElement): """ ```` element, specifies picture locking and resize behaviors. @@ -134,7 +134,7 @@ def new(cls): return OxmlElement('pic:cNvPicPr') -class CT_Picture(OxmlBaseElement): +class CT_Picture(BaseOxmlElement): """ ```` element, a DrawingML picture """ @@ -156,7 +156,7 @@ def new(cls, pic_id, filename, rId, cx, cy): return pic -class CT_PictureNonVisual(OxmlBaseElement): +class CT_PictureNonVisual(BaseOxmlElement): """ ```` element, non-visual picture properties """ @@ -170,7 +170,7 @@ def new(cls, pic_id, image_filename): return nvPicPr -class CT_Point2D(OxmlBaseElement): +class CT_Point2D(BaseOxmlElement): """ Used for ```` element, and perhaps others. Specifies an x, y coordinate (point). @@ -183,7 +183,7 @@ def new(cls, nsptagname_str, x, y): return elm -class CT_PositiveSize2D(OxmlBaseElement): +class CT_PositiveSize2D(BaseOxmlElement): """ Used for ```` element, and perhaps others later. Specifies the size of a DrawingML drawing. @@ -218,7 +218,7 @@ def new(cls, nsptagname_str, cx, cy): return elm -class CT_PresetGeometry2D(OxmlBaseElement): +class CT_PresetGeometry2D(BaseOxmlElement): """ ```` element, specifies an preset autoshape geometry, such as ``rect``. @@ -230,7 +230,7 @@ def new(cls, prst): return prstGeom -class CT_RelativeRect(OxmlBaseElement): +class CT_RelativeRect(BaseOxmlElement): """ ```` element, specifying picture should fill containing rectangle shape. @@ -240,7 +240,7 @@ def new(cls): return OxmlElement('a:fillRect') -class CT_ShapeProperties(OxmlBaseElement): +class CT_ShapeProperties(BaseOxmlElement): """ ```` element, specifies size and shape of picture container. """ @@ -252,7 +252,7 @@ def new(cls, cx, cy): return spPr -class CT_StretchInfoProperties(OxmlBaseElement): +class CT_StretchInfoProperties(BaseOxmlElement): """ ```` element, specifies how picture should fill its containing shape. @@ -264,7 +264,7 @@ def new(cls): return stretch -class CT_Transform2D(OxmlBaseElement): +class CT_Transform2D(BaseOxmlElement): """ ```` element, specifies size and shape of picture container. """ diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index baf5820a4..747a8eba0 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -6,49 +6,13 @@ from __future__ import absolute_import -from lxml import etree - from . import OxmlElement from .exceptions import ValidationError from .ns import qn -from .xmlchemy import serialize_for_reading - - -class OxmlBaseElement(etree.ElementBase): - """ - Base class for all custom element classes, to add standardized behavior - to all classes in one place. - """ - def first_child_found_in(self, *tagnames): - """ - Return the first child found with tag in *tagnames*, or None if - not found. - """ - for tagname in tagnames: - child = self.find(qn(tagname)) - if child is not None: - return child - return None - - def insert_element_before(self, elm, *tagnames): - successor = self.first_child_found_in(*tagnames) - if successor is not None: - successor.addprevious(elm) - else: - self.append(elm) - return elm - - @property - def xml(self): - """ - Return XML string for this element, suitable for testing purposes. - Pretty printed for readability and without an XML declaration at the - top. - """ - return serialize_for_reading(self) +from .xmlchemy import BaseOxmlElement -class CT_DecimalNumber(OxmlBaseElement): +class CT_DecimalNumber(BaseOxmlElement): """ Used for ````, ````, ```` and several others, containing a text representation of a decimal number (e.g. 42) in @@ -76,7 +40,7 @@ def val(self, val): self.set(qn('w:val'), decimal_number_str) -class CT_OnOff(OxmlBaseElement): +class CT_OnOff(BaseOxmlElement): """ Used for ````, ```` elements and others, containing a bool-ish string in its ``val`` attribute, xsd:boolean plus 'on' and 'off'. @@ -102,7 +66,7 @@ def val(self, value): self.set(val, '0') -class CT_String(OxmlBaseElement): +class CT_String(BaseOxmlElement): """ Used for ```` and ```` elements and others, containing a style name in its ``val`` attribute. diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 973e82356..53689cf5c 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -9,11 +9,12 @@ from . import OxmlElement from .exceptions import ValidationError from .ns import qn -from .shared import CT_String, OxmlBaseElement +from .shared import CT_String from .text import CT_P +from .xmlchemy import BaseOxmlElement -class CT_Row(OxmlBaseElement): +class CT_Row(BaseOxmlElement): """ ```` element """ @@ -47,7 +48,7 @@ def _append_tc(self, tc): return tc -class CT_Tbl(OxmlBaseElement): +class CT_Tbl(BaseOxmlElement): """ ```` element """ @@ -102,7 +103,7 @@ def _append_tr(self, tr): return tr -class CT_TblGrid(OxmlBaseElement): +class CT_TblGrid(BaseOxmlElement): """ ```` element, child of ````, holds ```` elements that define column count, width, etc. @@ -153,7 +154,7 @@ def first_child_found_in(self, *tagnames): return None -class CT_TblGridCol(OxmlBaseElement): +class CT_TblGridCol(BaseOxmlElement): """ ```` element, child of ````, defines a table column. @@ -166,7 +167,7 @@ def new(cls): return OxmlElement('w:gridCol') -class CT_TblPr(OxmlBaseElement): +class CT_TblPr(BaseOxmlElement): """ ```` element, child of ````, holds child elements that define table properties such as style and borders. @@ -203,7 +204,7 @@ def _insert_tblStyle(self, tblStyle): return tblStyle -class CT_Tc(OxmlBaseElement): +class CT_Tc(BaseOxmlElement): """ ```` table cell element """ diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 9a37d440c..fdb7bbad4 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -9,10 +9,11 @@ from ..enum.text import WD_UNDERLINE from .ns import nsdecls, qn from .parts.numbering import CT_NumPr -from .shared import CT_String, OxmlBaseElement +from .shared import CT_String +from .xmlchemy import BaseOxmlElement -class CT_Br(OxmlBaseElement): +class CT_Br(BaseOxmlElement): """ ```` element, indicating a line, page, or column break in a run. """ @@ -40,7 +41,7 @@ def type(self, type_str): self.set(qn('w:type'), type_str) -class CT_P(OxmlBaseElement): +class CT_P(BaseOxmlElement): """ ```` element, containing the properties and text for a paragraph. """ @@ -113,7 +114,7 @@ def _add_pPr(self): return pPr -class CT_PPr(OxmlBaseElement): +class CT_PPr(BaseOxmlElement): """ ```` element, containing the properties for a paragraph. """ @@ -213,7 +214,7 @@ def _insert_pStyle(self, pStyle): return pStyle -class CT_R(OxmlBaseElement): +class CT_R(BaseOxmlElement): """ ```` element, containing the properties and text for a run. """ @@ -320,7 +321,7 @@ def _add_rPr(self): return rPr -class CT_RPr(OxmlBaseElement): +class CT_RPr(BaseOxmlElement): """ ```` element, containing the properties for a run. """ @@ -812,7 +813,7 @@ def _add_u(self): return u -class CT_Text(OxmlBaseElement): +class CT_Text(BaseOxmlElement): """ ```` element, containing a sequence of characters within a run. """ @@ -826,7 +827,7 @@ def new(cls, text): return t -class CT_Underline(OxmlBaseElement): +class CT_Underline(BaseOxmlElement): """ ```` element, specifying the underlining style for a run. """ diff --git a/docx/oxml/xmlchemy.py b/docx/oxml/xmlchemy.py index 0173121be..8fca30304 100644 --- a/docx/oxml/xmlchemy.py +++ b/docx/oxml/xmlchemy.py @@ -11,6 +11,7 @@ import re +from .ns import qn from ..compat import Unicode @@ -87,3 +88,37 @@ def _parse_line(cls, line): match = cls._xml_elm_line_patt.match(line) front, attrs, close, text = [match.group(n) for n in range(1, 5)] return front, attrs, close, text + + +class BaseOxmlElement(etree.ElementBase): + """ + Base class for all custom element classes, to add standardized behavior + to all classes in one place. + """ + def first_child_found_in(self, *tagnames): + """ + Return the first child found with tag in *tagnames*, or None if + not found. + """ + for tagname in tagnames: + child = self.find(qn(tagname)) + if child is not None: + return child + return None + + def insert_element_before(self, elm, *tagnames): + successor = self.first_child_found_in(*tagnames) + if successor is not None: + successor.addprevious(elm) + else: + self.append(elm) + return elm + + @property + def xml(self): + """ + Return XML string for this element, suitable for testing purposes. + Pretty printed for readability and without an XML declaration at the + top. + """ + return serialize_for_reading(self) diff --git a/tests/oxml/test__init__.py b/tests/oxml/test__init__.py index 6db24d3ca..3f7947653 100644 --- a/tests/oxml/test__init__.py +++ b/tests/oxml/test__init__.py @@ -14,7 +14,7 @@ OxmlElement, oxml_parser, parse_xml, register_element_cls ) from docx.oxml.ns import qn -from docx.oxml.shared import OxmlBaseElement +from docx.oxml.shared import BaseOxmlElement class DescribeOxmlElement(object): @@ -128,5 +128,5 @@ def xml_text(self): # static fixture # =========================================================================== -class CustElmCls(OxmlBaseElement): +class CustElmCls(BaseOxmlElement): pass diff --git a/tests/oxml/test_shared.py b/tests/oxml/test_shared.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/unitutil.py b/tests/unitutil.py index 83f6ec812..1fc7d7590 100644 --- a/tests/unitutil.py +++ b/tests/unitutil.py @@ -8,7 +8,7 @@ from mock import create_autospec, Mock, patch, PropertyMock -from docx.oxml.shared import serialize_for_reading +from docx.oxml.xmlchemy import serialize_for_reading _thisdir = os.path.split(__file__)[0] From 57892dd4702c97213abe4b688f53e5619799f091 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 16:53:59 -0700 Subject: [PATCH 039/809] oxml: add unit tests for BaseOxmlElement --- docx/oxml/ns.py | 8 +++++ docx/oxml/xmlchemy.py | 11 ++++++- tests/oxml/test_ns.py | 4 +++ tests/oxml/test_xmlchemy.py | 63 +++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) diff --git a/docx/oxml/ns.py b/docx/oxml/ns.py index 0a8a15a34..d4b3014db 100644 --- a/docx/oxml/ns.py +++ b/docx/oxml/ns.py @@ -20,6 +20,8 @@ 'xml': ('http://www.w3.org/XML/1998/namespace') } +pfxmap = dict((value, key) for key, value in nsmap.items()) + class NamespacePrefixedTag(str): """ @@ -37,6 +39,12 @@ def __init__(self, nstag): def clark_name(self): return '{%s}%s' % (self._ns_uri, self._local_part) + @classmethod + def from_clark_name(cls, clark_name): + nsuri, local_name = clark_name[1:].split('}') + nstag = '%s:%s' % (pfxmap[nsuri], local_name) + return cls(nstag) + @property def local_part(self): """ diff --git a/docx/oxml/xmlchemy.py b/docx/oxml/xmlchemy.py index 8fca30304..ad7e28654 100644 --- a/docx/oxml/xmlchemy.py +++ b/docx/oxml/xmlchemy.py @@ -11,7 +11,7 @@ import re -from .ns import qn +from .ns import NamespacePrefixedTag, qn from ..compat import Unicode @@ -95,6 +95,11 @@ class BaseOxmlElement(etree.ElementBase): Base class for all custom element classes, to add standardized behavior to all classes in one place. """ + def __repr__(self): + return "<%s '<%s>' at 0x%0x>" % ( + self.__class__.__name__, self._nsptag, id(self) + ) + def first_child_found_in(self, *tagnames): """ Return the first child found with tag in *tagnames*, or None if @@ -122,3 +127,7 @@ def xml(self): top. """ return serialize_for_reading(self) + + @property + def _nsptag(self): + return NamespacePrefixedTag.from_clark_name(self.tag) diff --git a/tests/oxml/test_ns.py b/tests/oxml/test_ns.py index 630e70f46..d17d98340 100644 --- a/tests/oxml/test_ns.py +++ b/tests/oxml/test_ns.py @@ -20,6 +20,10 @@ def it_behaves_like_a_string_when_you_want_it_to(self, nsptag): def it_knows_its_clark_name(self, nsptag, clark_name): assert nsptag.clark_name == clark_name + def it_can_construct_from_a_clark_name(self, clark_name, nsptag): + _nsptag = NamespacePrefixedTag.from_clark_name(clark_name) + assert _nsptag == nsptag + def it_knows_its_local_part(self, nsptag, local_part): assert nsptag.local_part == local_part diff --git a/tests/oxml/test_xmlchemy.py b/tests/oxml/test_xmlchemy.py index 8fd4344c7..9a63a72e4 100644 --- a/tests/oxml/test_xmlchemy.py +++ b/tests/oxml/test_xmlchemy.py @@ -10,8 +10,71 @@ from docx.compat import Unicode from docx.oxml import parse_xml +from docx.oxml.ns import qn from docx.oxml.xmlchemy import serialize_for_reading, XmlString +from .unitdata.text import a_b, a_u, an_i, an_rPr + + +class DescribeBaseOxmlElement(object): + + def it_can_find_the_first_of_its_children_named_in_a_sequence( + self, first_fixture): + element, tagnames, matching_child = first_fixture + assert element.first_child_found_in(*tagnames) is matching_child + + def it_can_insert_an_element_before_named_successors( + self, insert_fixture): + element, child, tagnames, expected_xml = insert_fixture + element.insert_element_before(child, *tagnames) + assert element.xml == expected_xml + + # fixtures --------------------------------------------- + + @pytest.fixture(params=[ + ('iu', 'b', 'iu', 'biu'), + ('u', 'b', 'iu', 'bu'), + ('', 'b', 'iu', 'b'), + ('bu', 'i', 'u', 'biu'), + ('bi', 'u', '', 'biu'), + ]) + def insert_fixture(self, request): + present, new, successors, after = request.param + element = self.rPr_bldr(present).element + child = { + 'b': a_b(), 'i': an_i(), 'u': a_u() + }[new].with_nsdecls().element + tagnames = [('w:%s' % char) for char in successors] + expected_xml = self.rPr_bldr(after).xml() + return element, child, tagnames, expected_xml + + @pytest.fixture(params=[ + ('biu', 'iu', 'i'), + ('bu', 'iu', 'u'), + ('bi', 'u', None), + ('b', 'iu', None), + ('iu', 'biu', 'i'), + ('', 'biu', None), + ]) + def first_fixture(self, request): + present, matching, match = request.param + element = self.rPr_bldr(present).element + tagnames = [('w:%s' % char) for char in matching] + matching_child = element.find(qn('w:%s' % match)) if match else None + return element, tagnames, matching_child + + # fixture components --------------------------------------------- + + def rPr_bldr(self, children): + rPr_bldr = an_rPr().with_nsdecls() + if 'b' in children: + rPr_bldr.with_child(a_b()) + if 'i' in children: + rPr_bldr.with_child(an_i()) + if 'u' in children: + rPr_bldr.with_child(a_u()) + return rPr_bldr + class DescribeSerializeForReading(object): From d4559b856f055112be513064ae0b3ab701f4b510 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 19:02:42 -0700 Subject: [PATCH 040/809] xmlch: add BaseOxmlElement.remove_all() --- docx/oxml/xmlchemy.py | 10 +++++++ tests/oxml/test_xmlchemy.py | 59 ++++++++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/docx/oxml/xmlchemy.py b/docx/oxml/xmlchemy.py index ad7e28654..91ebff149 100644 --- a/docx/oxml/xmlchemy.py +++ b/docx/oxml/xmlchemy.py @@ -119,6 +119,16 @@ def insert_element_before(self, elm, *tagnames): self.append(elm) return elm + def remove_all(self, *tagnames): + """ + Remove all child elements whose tagname (e.g. 'a:p') appears in + *tagnames*. + """ + for tagname in tagnames: + matching = self.findall(qn(tagname)) + for child in matching: + self.remove(child) + @property def xml(self): """ diff --git a/tests/oxml/test_xmlchemy.py b/tests/oxml/test_xmlchemy.py index 9a63a72e4..b1a00aaf5 100644 --- a/tests/oxml/test_xmlchemy.py +++ b/tests/oxml/test_xmlchemy.py @@ -29,8 +29,29 @@ def it_can_insert_an_element_before_named_successors( element.insert_element_before(child, *tagnames) assert element.xml == expected_xml + def it_can_remove_all_children_with_name_in_sequence( + self, remove_fixture): + element, tagnames, expected_xml = remove_fixture + element.remove_all(*tagnames) + assert element.xml == expected_xml + # fixtures --------------------------------------------- + @pytest.fixture(params=[ + ('biu', 'iu', 'i'), + ('bu', 'iu', 'u'), + ('bi', 'u', None), + ('b', 'iu', None), + ('iu', 'biu', 'i'), + ('', 'biu', None), + ]) + def first_fixture(self, request): + present, matching, match = request.param + element = self.rPr_bldr(present).element + tagnames = self.nsptags(matching) + matching_child = element.find(qn('w:%s' % match)) if match else None + return element, tagnames, matching_child + @pytest.fixture(params=[ ('iu', 'b', 'iu', 'biu'), ('u', 'b', 'iu', 'bu'), @@ -49,30 +70,34 @@ def insert_fixture(self, request): return element, child, tagnames, expected_xml @pytest.fixture(params=[ - ('biu', 'iu', 'i'), - ('bu', 'iu', 'u'), - ('bi', 'u', None), - ('b', 'iu', None), - ('iu', 'biu', 'i'), - ('', 'biu', None), + ('biu', 'b', 'iu'), ('biu', 'bi', 'u'), ('bbiiuu', 'i', 'bbuu'), + ('biu', 'i', 'bu'), ('biu', 'bu', 'i'), ('bbiiuu', '', 'bbiiuu'), + ('biu', 'u', 'bi'), ('biu', 'ui', 'b'), ('bbiiuu', 'bi', 'uu'), + ('bu', 'i', 'bu'), ('', 'ui', ''), ]) - def first_fixture(self, request): - present, matching, match = request.param + def remove_fixture(self, request): + present, remove, after = request.param element = self.rPr_bldr(present).element - tagnames = [('w:%s' % char) for char in matching] - matching_child = element.find(qn('w:%s' % match)) if match else None - return element, tagnames, matching_child + tagnames = self.nsptags(remove) + expected_xml = self.rPr_bldr(after).xml() + return element, tagnames, expected_xml # fixture components --------------------------------------------- + def nsptags(self, letters): + return [('w:%s' % letter) for letter in letters] + def rPr_bldr(self, children): rPr_bldr = an_rPr().with_nsdecls() - if 'b' in children: - rPr_bldr.with_child(a_b()) - if 'i' in children: - rPr_bldr.with_child(an_i()) - if 'u' in children: - rPr_bldr.with_child(a_u()) + for char in children: + if char == 'b': + rPr_bldr.with_child(a_b()) + elif char == 'i': + rPr_bldr.with_child(an_i()) + elif char == 'u': + rPr_bldr.with_child(a_u()) + else: + raise NotImplementedError("got '%s'" % char) return rPr_bldr From aba0a294a40c91780f5298b724e2e4e4e90c0046 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 19:30:28 -0700 Subject: [PATCH 041/809] xmlch: add BaseOxmlElement.xpath() --- docx/opc/package.py | 4 ++-- docx/oxml/parts/numbering.py | 6 +++--- docx/oxml/parts/styles.py | 4 ++-- docx/oxml/xmlchemy.py | 11 ++++++++++- docx/parts/document.py | 3 +-- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/docx/opc/package.py b/docx/opc/package.py index dd0ea5073..b99b36eda 100644 --- a/docx/opc/package.py +++ b/docx/opc/package.py @@ -9,7 +9,7 @@ from .compat import cls_method_fn from .constants import RELATIONSHIP_TYPE as RT -from .oxml import CT_Relationships, nsmap, serialize_part_xml +from .oxml import CT_Relationships, serialize_part_xml from .packuri import PACKAGE_URI, PackURI from .pkgreader import PackageReader from .pkgwriter import PackageWriter @@ -301,7 +301,7 @@ def _rel_ref_count(self, rId): identified by *rId*. """ assert self._element is not None - rIds = self._element.xpath('//@r:id', namespaces=nsmap) + rIds = self._element.xpath('//@r:id') return len([_rId for _rId in rIds if _rId == rId]) # ---------------------------------------------------------------- diff --git a/docx/oxml/parts/numbering.py b/docx/oxml/parts/numbering.py index 04242f58e..2f7c6cc29 100644 --- a/docx/oxml/parts/numbering.py +++ b/docx/oxml/parts/numbering.py @@ -6,7 +6,7 @@ from .. import OxmlElement from ..shared import CT_DecimalNumber -from ..ns import nsmap, qn +from ..ns import qn from ..xmlchemy import BaseOxmlElement @@ -181,7 +181,7 @@ def num_having_numId(self, numId): """ xpath = './w:num[@w:numId="%d"]' % numId try: - return self.xpath(xpath, namespaces=nsmap)[0] + return self.xpath(xpath)[0] except IndexError: raise KeyError('no element with numId %d' % numId) @@ -195,7 +195,7 @@ def _next_numId(self): 1 and filling any gaps in numbering between existing ```` elements. """ - numId_strs = self.xpath('./w:num/@w:numId', namespaces=nsmap) + 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): if num not in num_ids: diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index f0072a307..b8fcb61e3 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -4,7 +4,7 @@ Custom element classes related to the styles part """ -from ..ns import nsmap, qn +from ..ns import qn from ..xmlchemy import BaseOxmlElement @@ -29,7 +29,7 @@ def style_having_styleId(self, styleId): """ xpath = './w:style[@w:styleId="%s"]' % styleId try: - return self.xpath(xpath, namespaces=nsmap)[0] + return self.xpath(xpath)[0] except IndexError: raise KeyError('no element with styleId %d' % styleId) diff --git a/docx/oxml/xmlchemy.py b/docx/oxml/xmlchemy.py index 91ebff149..ed3930bb8 100644 --- a/docx/oxml/xmlchemy.py +++ b/docx/oxml/xmlchemy.py @@ -11,7 +11,7 @@ import re -from .ns import NamespacePrefixedTag, qn +from .ns import NamespacePrefixedTag, nsmap, qn from ..compat import Unicode @@ -138,6 +138,15 @@ def xml(self): """ return serialize_for_reading(self) + 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 + ) + @property def _nsptag(self): return NamespacePrefixedTag.from_clark_name(self.tag) diff --git a/docx/parts/document.py b/docx/parts/document.py index 757a92e17..e9d520724 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -12,7 +12,6 @@ from ..opc.oxml import serialize_part_xml from ..opc.package import Part from ..oxml import parse_xml -from ..oxml.ns import nsmap from ..shape import InlineShape from ..shared import lazyproperty, Parented from ..table import Table @@ -214,4 +213,4 @@ def add_picture(self, image_descriptor): def _inline_lst(self): body = self._body xpath = './w:p/w:r/w:drawing/wp:inline' - return body.xpath(xpath, namespaces=nsmap) + return body.xpath(xpath) From be5b936f17f8882c5f15c5dde7b64aee3ae11eaf Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 21:09:46 -0700 Subject: [PATCH 042/809] xmlch: transplant xmlchemy meta classes Entailed basic simple types. --- docx/exc.py | 18 ++ docx/oxml/simpletypes.py | 151 +++++++++ docx/oxml/xmlchemy.py | 603 +++++++++++++++++++++++++++++++++- tests/oxml/test_xmlchemy.py | 623 +++++++++++++++++++++++++++++++++++- 4 files changed, 1391 insertions(+), 4 deletions(-) create mode 100644 docx/exc.py create mode 100644 docx/oxml/simpletypes.py diff --git a/docx/exc.py b/docx/exc.py new file mode 100644 index 000000000..1cd4d6194 --- /dev/null +++ b/docx/exc.py @@ -0,0 +1,18 @@ +# encoding: utf-8 + +""" +Exceptions used with python-docx. + +The base exception class is PythonDocxError. +""" + + +class PythonDocxError(Exception): + """Generic error class.""" + + +class InvalidXmlError(PythonDocxError): + """ + Raised when a value is encountered in the XML that is not valid according + to the schema. + """ diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py new file mode 100644 index 000000000..54183871d --- /dev/null +++ b/docx/oxml/simpletypes.py @@ -0,0 +1,151 @@ +# encoding: utf-8 + +""" +Simple type classes, providing validation and format translation for values +stored in XML element attributes. Naming generally corresponds to the simple +type in the associated XML schema. +""" + +from __future__ import absolute_import, print_function + + +class BaseSimpleType(object): + + @classmethod + def from_xml(cls, str_value): + return cls.convert_from_xml(str_value) + + @classmethod + def to_xml(cls, value): + cls.validate(value) + str_value = cls.convert_to_xml(value) + return str_value + + @classmethod + def validate_int(cls, value): + if not isinstance(value, int): + 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) + ) + + @classmethod + def validate_string(cls, value): + if isinstance(value, str): + return value + try: + if isinstance(value, basestring): + return value + except NameError: # means we're on Python 3 + pass + raise TypeError( + "value must be a string, got %s" % type(value) + ) + + +class BaseStringType(BaseSimpleType): + + @classmethod + def convert_from_xml(cls, str_value): + return str_value + + @classmethod + def convert_to_xml(cls, value): + return value + + @classmethod + def validate(cls, value): + cls.validate_string(value) + + +class BaseIntType(BaseSimpleType): + + @classmethod + def convert_from_xml(cls, str_value): + return int(str_value) + + @classmethod + def convert_to_xml(cls, value): + return str(value) + + @classmethod + def validate(cls, value): + cls.validate_int(value) + + +class XsdAnyUri(BaseStringType): + """ + There's a regular expression this is supposed to meet but so far thinking + spending cycles on validating wouldn't be worth it for the number of + programming errors it would catch. + """ + + +class XsdBoolean(BaseSimpleType): + + @classmethod + def convert_from_xml(cls, str_value): + return str_value in ('1', 'true') + + @classmethod + def convert_to_xml(cls, value): + return {True: '1', False: '0'}[value] + + @classmethod + def validate(cls, value): + if value not in (True, False): + raise TypeError( + "only True or False (and possibly None) may be assigned, got" + " '%s'" % value + ) + + +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 + ) + + +class XsdString(BaseStringType): + pass + + +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) diff --git a/docx/oxml/xmlchemy.py b/docx/oxml/xmlchemy.py index ed3930bb8..e9aeb0c96 100644 --- a/docx/oxml/xmlchemy.py +++ b/docx/oxml/xmlchemy.py @@ -11,8 +11,11 @@ import re -from .ns import NamespacePrefixedTag, nsmap, qn +from . import OxmlElement from ..compat import Unicode +from ..exc import InvalidXmlError +from .ns import NamespacePrefixedTag, nsmap, qn +from ..shared import lazyproperty def serialize_for_reading(element): @@ -90,11 +93,609 @@ def _parse_line(cls, line): return front, attrs, close, text +class MetaOxmlElement(type): + """ + Metaclass for BaseOxmlElement + """ + def __init__(cls, clsname, bases, clsdict): + dispatchable = ( + OneAndOnlyOne, OneOrMore, OptionalAttribute, RequiredAttribute, + ZeroOrMore, ZeroOrOne, ZeroOrOneChoice + ) + for key, value in clsdict.items(): + if isinstance(value, dispatchable): + value.populate_class_members(cls, key) + + +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 + self._simple_type = simple_type + + def populate_class_members(self, element_cls, prop_name): + """ + Add the appropriate methods to *element_cls*. + """ + self._element_cls = element_cls + self._prop_name = prop_name + + self._add_attr_property() + + def _add_attr_property(self): + """ + Add a read/write ``{prop_name}`` property to the element class that + returns the interpreted value of this attribute on access and changes + the attribute value to its ST_* counterpart on assignment. + """ + property_ = property(self._getter, self._setter, None) + # assign unconditionally to overwrite element name definition + setattr(self._element_cls, self._prop_name, property_) + + @property + def _clark_name(self): + if ':' in self._attr_name: + return qn(self._attr_name) + return self._attr_name + + +class OptionalAttribute(BaseAttribute): + """ + Defines an optional attribute on a custom element class. An optional + 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 + + @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: + return self._default + return self._simple_type.from_xml(attr_str_value) + get_attr_value.__doc__ = self._docstring + return get_attr_value + + @property + 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, 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 + 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._attr_name in obj.attrib: + del obj.attrib[self._attr_name] + return + str_value = self._simple_type.to_xml(value) + obj.set(self._clark_name, str_value) + return set_attr_value + + +class RequiredAttribute(BaseAttribute): + """ + Defines a required attribute on a custom element class. A required + attribute is assumed to be present for reading, so does not have + a default value; its actual value is always used. If missing on read, + an |InvalidXmlError| is raised. It also does not remove the attribute if + |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) + ) + return self._simple_type.from_xml(attr_str_value) + get_attr_value.__doc__ = self._docstring + return get_attr_value + + @property + 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) + ) + + @property + 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 + + +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 + self._successors = successors + + def populate_class_members(self, element_cls, prop_name): + """ + Baseline behavior for adding the appropriate methods to + *element_cls*. + """ + self._element_cls = element_cls + self._prop_name = prop_name + + 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() + for key, value in attrs.items(): + setattr(child, key, value) + insert_method = getattr(obj, self._insert_method_name) + insert_method(child) + return child + + _add_child.__doc__ = ( + '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) + + def _add_creator(self): + """ + Add a ``_new_{prop_name}()`` method to the element class that creates + a new, empty element of the correct type, having no attributes. + """ + creator = self._creator + creator.__doc__ = ( + 'Return a "loose", newly created ``<%s>`` element having no attri' + 'butes, text, or children.' % self._nsptagname + ) + self._add_to_class(self._new_method_name, creator) + + def _add_getter(self): + """ + Add a read-only ``{prop_name}`` property to the element class for + this child element. + """ + property_ = property(self._getter, None, None) + # assign unconditionally to overwrite element name definition + setattr(self._element_cls, self._prop_name, property_) + + 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 + ) + self._add_to_class(self._insert_method_name, _insert_child) + + 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 + 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 + + def _add_to_class(self, name, method): + """ + Add *method* to the target class as *name*, unless *name* is already + defined on the class. + """ + if hasattr(self._element_cls, name): + return + setattr(self._element_cls, name, method) + + @property + 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 + def _getter(self): + """ + Return a function object suitable for the "get" side of the property + 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 + ) + return get_child_element + + @lazyproperty + def _insert_method_name(self): + return '_insert_%s' % self._prop_name + + @property + 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 + ) + return get_child_element_list + + @lazyproperty + def _remove_method_name(self): + return '_remove_%s' % self._prop_name + + @lazyproperty + def _new_method_name(self): + return '_new_%s' % self._prop_name + + +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): + """ + Add the appropriate methods to *element_cls*. + """ + self._element_cls = element_cls + self._group_prop_name = group_prop_name + self._successors = successors + + self._add_getter() + self._add_creator() + self._add_inserter() + self._add_adder() + self._add_get_or_change_to_method() + + 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() + 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.' + ) % self._nsptagname + 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 + 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 + + @lazyproperty + def _remove_group_method_name(self): + 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) + + 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 + ) + self._add_getter() + + @property + 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 + ) + return child + + get_child_element.__doc__ = ( + 'Required ``<%s>`` child element.' + % self._nsptagname + ) + return get_child_element + + +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 + ) + self._add_list_getter() + self._add_creator() + self._add_inserter() + self._add_adder() + self._add_public_adder() + delattr(element_cls, 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 + ) + self._add_to_class(self._public_add_method_name, add_child) + + @lazyproperty + def _public_add_method_name(self): + """ + add_childElement() is public API for a repeating element, allowing + new elements to be added to the sequence. May be overridden to + provide a friendlier API to clients having domain appropriate + parameter names for required attributes. + """ + return 'add_%s' % self._prop_name + + +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 + ) + self._add_list_getter() + self._add_creator() + self._add_inserter() + self._add_adder() + delattr(element_cls, prop_name) + + +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*. + """ + super(ZeroOrOne, self).populate_class_members(element_cls, prop_name) + self._add_getter() + self._add_creator() + self._add_inserter() + self._add_adder() + self._add_get_or_adder() + self._add_remover() + + 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.' + ) % self._nsptagname + self._add_to_class(self._get_or_add_method_name, get_or_add_child) + + 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.' + ) % 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 + + +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 + + 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 + ) + self._add_choice_getter() + for choice in self._choices: + choice.populate_class_members( + element_cls, self._prop_name, self._successors + ) + self._add_group_remover() + + def _add_choice_getter(self): + """ + Add a read-only ``{prop_name}`` property to the element class that + returns the present member of this group, or |None| if none are + present. + """ + property_ = property(self._choice_getter, None, None) + # assign unconditionally to overwrite element name definition + setattr(self._element_cls, self._prop_name, property_) + + 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 + ) + + @property + 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 get_group_member_element + + @lazyproperty + def _member_nsptagnames(self): + """ + Sequence of namespace-prefixed tagnames, one for each of the member + elements of this choice group. + """ + return [choice.nsptagname for choice in self._choices] + + @lazyproperty + def _remove_choice_group_method_name(self): + return '_remove_%s' % self._prop_name + + class BaseOxmlElement(etree.ElementBase): """ Base class for all custom element classes, to add standardized behavior to all classes in one place. """ + + __metaclass__ = MetaOxmlElement + def __repr__(self): return "<%s '<%s>' at 0x%0x>" % ( self.__class__.__name__, self._nsptag, id(self) diff --git a/tests/oxml/test_xmlchemy.py b/tests/oxml/test_xmlchemy.py index b1a00aaf5..356428ed2 100644 --- a/tests/oxml/test_xmlchemy.py +++ b/tests/oxml/test_xmlchemy.py @@ -9,10 +9,17 @@ import pytest from docx.compat import Unicode -from docx.oxml import parse_xml +from docx.exc import InvalidXmlError +from docx.oxml import parse_xml, register_element_cls from docx.oxml.ns import qn -from docx.oxml.xmlchemy import serialize_for_reading, XmlString - +from docx.oxml.simpletypes import BaseIntType +from docx.oxml.xmlchemy import ( + BaseOxmlElement, Choice, serialize_for_reading, OneOrMore, OneAndOnlyOne, + OptionalAttribute, RequiredAttribute, ZeroOrMore, ZeroOrOne, + ZeroOrOneChoice, XmlString +) + +from ..unitdata import BaseBuilder from .unitdata.text import a_b, a_u, an_i, an_rPr @@ -212,3 +219,613 @@ def xml_line_case(self, request): } line, other, differs = cases[request.param] return line, other, differs + + +class DescribeChoice(object): + + def it_adds_a_getter_property_for_the_choice_element( + self, getter_fixture): + parent, expected_choice = getter_fixture + assert parent.choice is expected_choice + + def it_adds_a_creator_method_for_the_child_element(self, new_fixture): + parent, expected_xml = new_fixture + choice = parent._new_choice() + assert choice.xml == expected_xml + + def it_adds_an_insert_method_for_the_child_element(self, insert_fixture): + parent, choice, expected_xml = insert_fixture + parent._insert_choice(choice) + assert parent.xml == expected_xml + assert parent._insert_choice.__doc__.startswith( + 'Return the passed ```` ' + ) + + def it_adds_an_add_method_for_the_child_element(self, add_fixture): + parent, expected_xml = add_fixture + choice = parent._add_choice() + assert parent.xml == expected_xml + assert isinstance(choice, CT_Choice) + assert parent._add_choice.__doc__.startswith( + 'Add a new ```` child element ' + ) + + def it_adds_a_get_or_change_to_method_for_the_child_element( + self, get_or_change_to_fixture): + parent, expected_xml = get_or_change_to_fixture + choice = parent.get_or_change_to_choice() + assert isinstance(choice, CT_Choice) + assert parent.xml == expected_xml + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def add_fixture(self): + parent = self.parent_bldr().element + expected_xml = self.parent_bldr('choice').xml() + return parent, expected_xml + + @pytest.fixture(params=[ + ('choice2', 'choice'), + (None, 'choice'), + ('choice', 'choice'), + ]) + def get_or_change_to_fixture(self, request): + before_member_tag, after_member_tag = request.param + parent = self.parent_bldr(before_member_tag).element + expected_xml = self.parent_bldr(after_member_tag).xml() + return parent, expected_xml + + @pytest.fixture(params=['choice', None]) + def getter_fixture(self, request): + choice_tag = request.param + parent = self.parent_bldr(choice_tag).element + expected_choice = parent.find(qn('w:choice')) # None if not found + return parent, expected_choice + + @pytest.fixture + def insert_fixture(self): + parent = ( + a_parent().with_nsdecls().with_child( + an_oomChild()).with_child( + an_oooChild()) + ).element + choice = a_choice().with_nsdecls().element + expected_xml = ( + a_parent().with_nsdecls().with_child( + a_choice()).with_child( + an_oomChild()).with_child( + an_oooChild()) + ).xml() + return parent, choice, expected_xml + + @pytest.fixture + def new_fixture(self): + parent = self.parent_bldr().element + expected_xml = a_choice().with_nsdecls().xml() + return parent, expected_xml + + # fixture components --------------------------------------------- + + def parent_bldr(self, choice_tag=None): + parent_bldr = a_parent().with_nsdecls() + if choice_tag == 'choice': + parent_bldr.with_child(a_choice()) + if choice_tag == 'choice2': + parent_bldr.with_child(a_choice2()) + return parent_bldr + + +class DescribeOneAndOnlyOne(object): + + def it_adds_a_getter_property_for_the_child_element(self, getter_fixture): + parent, oooChild = getter_fixture + assert parent.oooChild is oooChild + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def getter_fixture(self): + parent = a_parent().with_nsdecls().with_child(an_oooChild()).element + oooChild = parent.find(qn('w:oooChild')) + return parent, oooChild + + +class DescribeOneOrMore(object): + + def it_adds_a_getter_property_for_the_child_element_list( + self, getter_fixture): + parent, oomChild = getter_fixture + assert parent.oomChild_lst[0] is oomChild + + def it_adds_a_creator_method_for_the_child_element(self, new_fixture): + parent, expected_xml = new_fixture + oomChild = parent._new_oomChild() + assert oomChild.xml == expected_xml + + def it_adds_an_insert_method_for_the_child_element(self, insert_fixture): + parent, oomChild, expected_xml = insert_fixture + parent._insert_oomChild(oomChild) + assert parent.xml == expected_xml + assert parent._insert_oomChild.__doc__.startswith( + 'Return the passed ```` ' + ) + + def it_adds_a_private_add_method_for_the_child_element(self, add_fixture): + parent, expected_xml = add_fixture + oomChild = parent._add_oomChild() + assert parent.xml == expected_xml + assert isinstance(oomChild, CT_OomChild) + assert parent._add_oomChild.__doc__.startswith( + 'Add a new ```` child element ' + ) + + def it_adds_a_public_add_method_for_the_child_element(self, add_fixture): + parent, expected_xml = add_fixture + oomChild = parent.add_oomChild() + assert parent.xml == expected_xml + assert isinstance(oomChild, CT_OomChild) + assert parent._add_oomChild.__doc__.startswith( + 'Add a new ```` child element ' + ) + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def add_fixture(self): + parent = self.parent_bldr(False).element + expected_xml = self.parent_bldr(True).xml() + return parent, expected_xml + + @pytest.fixture + def getter_fixture(self): + parent = self.parent_bldr(True).element + oomChild = parent.find(qn('w:oomChild')) + return parent, oomChild + + @pytest.fixture + def insert_fixture(self): + parent = ( + a_parent().with_nsdecls().with_child( + an_oooChild()).with_child( + a_zomChild()).with_child( + a_zooChild()) + ).element + oomChild = an_oomChild().with_nsdecls().element + expected_xml = ( + a_parent().with_nsdecls().with_child( + an_oomChild()).with_child( + an_oooChild()).with_child( + a_zomChild()).with_child( + a_zooChild()) + ).xml() + return parent, oomChild, expected_xml + + @pytest.fixture + def new_fixture(self): + parent = self.parent_bldr(False).element + expected_xml = an_oomChild().with_nsdecls().xml() + return parent, expected_xml + + # fixture components --------------------------------------------- + + def parent_bldr(self, oomChild_is_present): + parent_bldr = a_parent().with_nsdecls() + if oomChild_is_present: + parent_bldr.with_child(an_oomChild()) + return parent_bldr + + +class DescribeOptionalAttribute(object): + + def it_adds_a_getter_property_for_the_attr_value(self, getter_fixture): + parent, optAttr_python_value = getter_fixture + assert parent.optAttr == optAttr_python_value + + def it_adds_a_setter_property_for_the_attr(self, setter_fixture): + parent, value, expected_xml = setter_fixture + parent.optAttr = value + assert parent.xml == expected_xml + + def it_adds_a_docstring_for_the_property(self): + assert CT_Parent.optAttr.__doc__.startswith( + "ST_IntegerType type-converted value of " + ) + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def getter_fixture(self): + parent = a_parent().with_nsdecls().with_optAttr('24').element + return parent, 24 + + @pytest.fixture + def setter_fixture(self): + parent = a_parent().with_nsdecls().with_optAttr('42').element + value = 36 + expected_xml = a_parent().with_nsdecls().with_optAttr(value).xml() + return parent, value, expected_xml + + +class DescribeRequiredAttribute(object): + + def it_adds_a_getter_property_for_the_attr_value(self, getter_fixture): + parent, reqAttr_python_value = getter_fixture + assert parent.reqAttr == reqAttr_python_value + + def it_adds_a_setter_property_for_the_attr(self, setter_fixture): + parent, value, expected_xml = setter_fixture + parent.reqAttr = value + assert parent.xml == expected_xml + + def it_adds_a_docstring_for_the_property(self): + assert CT_Parent.reqAttr.__doc__.startswith( + "ST_IntegerType type-converted value of " + ) + + def it_raises_on_get_when_attribute_not_present(self): + parent = a_parent().with_nsdecls().element + with pytest.raises(InvalidXmlError): + parent.reqAttr + + def it_raises_on_assign_invalid_value(self, invalid_assign_fixture): + parent, value, expected_exception = invalid_assign_fixture + with pytest.raises(expected_exception): + parent.reqAttr = value + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def getter_fixture(self): + parent = a_parent().with_nsdecls().with_reqAttr('42').element + return parent, 42 + + @pytest.fixture(params=[ + (None, TypeError), + (-4, ValueError), + ('2', TypeError), + ]) + def invalid_assign_fixture(self, request): + invalid_value, expected_exception = request.param + parent = a_parent().with_nsdecls().with_reqAttr(1).element + return parent, invalid_value, expected_exception + + @pytest.fixture + def setter_fixture(self): + parent = a_parent().with_nsdecls().with_reqAttr('42').element + value = 24 + expected_xml = a_parent().with_nsdecls().with_reqAttr(value).xml() + return parent, value, expected_xml + + +class DescribeZeroOrMore(object): + + def it_adds_a_getter_property_for_the_child_element_list( + self, getter_fixture): + parent, zomChild = getter_fixture + assert parent.zomChild_lst[0] is zomChild + + def it_adds_a_creator_method_for_the_child_element(self, new_fixture): + parent, expected_xml = new_fixture + zomChild = parent._new_zomChild() + assert zomChild.xml == expected_xml + + def it_adds_an_insert_method_for_the_child_element(self, insert_fixture): + parent, zomChild, expected_xml = insert_fixture + parent._insert_zomChild(zomChild) + assert parent.xml == expected_xml + assert parent._insert_zomChild.__doc__.startswith( + 'Return the passed ```` ' + ) + + def it_adds_an_add_method_for_the_child_element(self, add_fixture): + parent, expected_xml = add_fixture + zomChild = parent._add_zomChild() + assert parent.xml == expected_xml + assert isinstance(zomChild, CT_ZomChild) + assert parent._add_zomChild.__doc__.startswith( + 'Add a new ```` child element ' + ) + + def it_removes_the_property_root_name_used_for_declaration(self): + assert not hasattr(CT_Parent, 'zomChild') + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def add_fixture(self): + parent = self.parent_bldr(False).element + expected_xml = self.parent_bldr(True).xml() + return parent, expected_xml + + @pytest.fixture + def getter_fixture(self): + parent = self.parent_bldr(True).element + zomChild = parent.find(qn('w:zomChild')) + return parent, zomChild + + @pytest.fixture + def insert_fixture(self): + parent = ( + a_parent().with_nsdecls().with_child( + an_oomChild()).with_child( + an_oooChild()).with_child( + a_zooChild()) + ).element + zomChild = a_zomChild().with_nsdecls().element + expected_xml = ( + a_parent().with_nsdecls().with_child( + an_oomChild()).with_child( + an_oooChild()).with_child( + a_zomChild()).with_child( + a_zooChild()) + ).xml() + return parent, zomChild, expected_xml + + @pytest.fixture + def new_fixture(self): + parent = self.parent_bldr(False).element + expected_xml = a_zomChild().with_nsdecls().xml() + return parent, expected_xml + + def parent_bldr(self, zomChild_is_present): + parent_bldr = a_parent().with_nsdecls() + if zomChild_is_present: + parent_bldr.with_child(a_zomChild()) + return parent_bldr + + +class DescribeZeroOrOne(object): + + def it_adds_a_getter_property_for_the_child_element(self, getter_fixture): + parent, zooChild = getter_fixture + assert parent.zooChild is zooChild + + def it_adds_an_add_method_for_the_child_element(self, add_fixture): + parent, expected_xml = add_fixture + zooChild = parent._add_zooChild() + assert parent.xml == expected_xml + assert isinstance(zooChild, CT_ZooChild) + assert parent._add_zooChild.__doc__.startswith( + 'Add a new ```` child element ' + ) + + def it_adds_an_insert_method_for_the_child_element(self, insert_fixture): + parent, zooChild, expected_xml = insert_fixture + parent._insert_zooChild(zooChild) + assert parent.xml == expected_xml + assert parent._insert_zooChild.__doc__.startswith( + 'Return the passed ```` ' + ) + + def it_adds_a_get_or_add_method_for_the_child_element( + self, get_or_add_fixture): + parent, expected_xml = get_or_add_fixture + zooChild = parent.get_or_add_zooChild() + assert isinstance(zooChild, CT_ZooChild) + assert parent.xml == expected_xml + + def it_adds_a_remover_method_for_the_child_element(self, remove_fixture): + parent, expected_xml = remove_fixture + parent._remove_zooChild() + assert parent.xml == expected_xml + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def add_fixture(self): + parent = self.parent_bldr(False).element + expected_xml = self.parent_bldr(True).xml() + return parent, expected_xml + + @pytest.fixture(params=[True, False]) + def getter_fixture(self, request): + zooChild_is_present = request.param + parent = self.parent_bldr(zooChild_is_present).element + zooChild = parent.find(qn('w:zooChild')) # None if not found + return parent, zooChild + + @pytest.fixture(params=[True, False]) + def get_or_add_fixture(self, request): + zooChild_is_present = request.param + parent = self.parent_bldr(zooChild_is_present).element + expected_xml = self.parent_bldr(True).xml() + return parent, expected_xml + + @pytest.fixture + def insert_fixture(self): + parent = ( + a_parent().with_nsdecls().with_child( + an_oomChild()).with_child( + an_oooChild()).with_child( + a_zomChild()) + ).element + zooChild = a_zooChild().with_nsdecls().element + expected_xml = ( + a_parent().with_nsdecls().with_child( + an_oomChild()).with_child( + an_oooChild()).with_child( + a_zomChild()).with_child( + a_zooChild()) + ).xml() + return parent, zooChild, expected_xml + + @pytest.fixture(params=[True, False]) + def remove_fixture(self, request): + zooChild_is_present = request.param + parent = self.parent_bldr(zooChild_is_present).element + expected_xml = self.parent_bldr(False).xml() + return parent, expected_xml + + # fixture components --------------------------------------------- + + def parent_bldr(self, zooChild_is_present): + parent_bldr = a_parent().with_nsdecls() + if zooChild_is_present: + parent_bldr.with_child(a_zooChild()) + return parent_bldr + + +class DescribeZeroOrOneChoice(object): + + def it_adds_a_getter_for_the_current_choice(self, getter_fixture): + parent, expected_choice = getter_fixture + assert parent.eg_zooChoice is expected_choice + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[None, 'choice', 'choice2']) + def getter_fixture(self, request): + choice_tag = request.param + parent = self.parent_bldr(choice_tag).element + tagname = 'w:%s' % choice_tag + expected_choice = parent.find(qn(tagname)) # None if not found + return parent, expected_choice + + # fixture components --------------------------------------------- + + def parent_bldr(self, choice_tag=None): + parent_bldr = a_parent().with_nsdecls() + if choice_tag == 'choice': + parent_bldr.with_child(a_choice()) + if choice_tag == 'choice2': + parent_bldr.with_child(a_choice2()) + return parent_bldr + + +# -------------------------------------------------------------------- +# static shared fixture +# -------------------------------------------------------------------- + +class ST_IntegerType(BaseIntType): + + @classmethod + def validate(cls, value): + cls.validate_int(value) + if value < 1 or value > 42: + raise ValueError( + "value must be in range 1 to 42 inclusive" + ) + + +class CT_Parent(BaseOxmlElement): + """ + ```` element, an invented element for use in testing. + """ + eg_zooChoice = ZeroOrOneChoice( + (Choice('w:choice'), Choice('w:choice2')), + successors=('w:oomChild', 'w:oooChild') + ) + oomChild = OneOrMore('w:oomChild', successors=( + 'w:oooChild', 'w:zomChild', 'w:zooChild' + )) + oooChild = OneAndOnlyOne('w:oooChild') + zomChild = ZeroOrMore('w:zomChild', successors=('w:zooChild',)) + zooChild = ZeroOrOne('w:zooChild', successors=()) + optAttr = OptionalAttribute('optAttr', ST_IntegerType) + reqAttr = RequiredAttribute('reqAttr', ST_IntegerType) + + +class CT_Choice(BaseOxmlElement): + """ + ```` element + """ + + +class CT_OomChild(BaseOxmlElement): + """ + Oom standing for 'OneOrMore', ```` element, representing a + child element that can appear multiple times in sequence, but must appear + at least once. + """ + + +class CT_ZomChild(BaseOxmlElement): + """ + Zom standing for 'ZeroOrMore', ```` element, representing an + optional child element that can appear multiple times in sequence. + """ + + +class CT_ZooChild(BaseOxmlElement): + """ + Zoo standing for 'ZeroOrOne', ```` element, an invented + element for use in testing. + """ + + +register_element_cls('w:parent', CT_Parent) +register_element_cls('w:choice', CT_Choice) +register_element_cls('w:oomChild', CT_OomChild) +register_element_cls('w:zomChild', CT_ZomChild) +register_element_cls('w:zooChild', CT_ZooChild) + + +class CT_ChoiceBuilder(BaseBuilder): + __tag__ = 'w:choice' + __nspfxs__ = ('w',) + __attrs__ = () + + +class CT_Choice2Builder(BaseBuilder): + __tag__ = 'w:choice2' + __nspfxs__ = ('w',) + __attrs__ = () + + +class CT_ParentBuilder(BaseBuilder): + __tag__ = 'w:parent' + __nspfxs__ = ('w',) + __attrs__ = ('optAttr', 'reqAttr') + + +class CT_OomChildBuilder(BaseBuilder): + __tag__ = 'w:oomChild' + __nspfxs__ = ('w',) + __attrs__ = () + + +class CT_OooChildBuilder(BaseBuilder): + __tag__ = 'w:oooChild' + __nspfxs__ = ('w',) + __attrs__ = () + + +class CT_ZomChildBuilder(BaseBuilder): + __tag__ = 'w:zomChild' + __nspfxs__ = ('w',) + __attrs__ = () + + +class CT_ZooChildBuilder(BaseBuilder): + __tag__ = 'w:zooChild' + __nspfxs__ = ('w',) + __attrs__ = () + + +def a_choice(): + return CT_ChoiceBuilder() + + +def a_choice2(): + return CT_Choice2Builder() + + +def a_parent(): + return CT_ParentBuilder() + + +def a_zomChild(): + return CT_ZomChildBuilder() + + +def a_zooChild(): + return CT_ZooChildBuilder() + + +def an_oomChild(): + return CT_OomChildBuilder() + + +def an_oooChild(): + return CT_OooChildBuilder() From 3d8d6a6e52e9b27e0a2c30d09682e22218835a4f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 21:32:02 -0700 Subject: [PATCH 043/809] oxml: reformat custom class registrations and rename ValidationError to InvalidXmlError --- docx/{exc.py => exceptions.py} | 7 --- docx/oxml/__init__.py | 92 +++++++++++++++++----------------- docx/oxml/exceptions.py | 6 ++- docx/oxml/shared.py | 4 +- docx/oxml/table.py | 6 +-- docx/oxml/xmlchemy.py | 2 +- tests/oxml/test_xmlchemy.py | 2 +- 7 files changed, 58 insertions(+), 61 deletions(-) rename docx/{exc.py => exceptions.py} (52%) diff --git a/docx/exc.py b/docx/exceptions.py similarity index 52% rename from docx/exc.py rename to docx/exceptions.py index 1cd4d6194..0263e10a4 100644 --- a/docx/exc.py +++ b/docx/exceptions.py @@ -9,10 +9,3 @@ class PythonDocxError(Exception): """Generic error class.""" - - -class InvalidXmlError(PythonDocxError): - """ - Raised when a value is encountered in the XML that is not valid according - to the schema. - """ diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 7d6350929..816ec3976 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -70,70 +70,70 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, CT_GraphicalObjectData, CT_Inline, CT_Picture, CT_PositiveSize2D ) -register_element_cls('a:blip', CT_Blip) -register_element_cls('a:graphic', CT_GraphicalObject) +register_element_cls('a:blip', CT_Blip) +register_element_cls('a:graphic', CT_GraphicalObject) register_element_cls('a:graphicData', CT_GraphicalObjectData) -register_element_cls('pic:blipFill', CT_BlipFillProperties) -register_element_cls('pic:pic', CT_Picture) -register_element_cls('wp:extent', CT_PositiveSize2D) -register_element_cls('wp:inline', CT_Inline) +register_element_cls('pic:blipFill', CT_BlipFillProperties) +register_element_cls('pic:pic', CT_Picture) +register_element_cls('wp:extent', CT_PositiveSize2D) +register_element_cls('wp:inline', CT_Inline) from docx.oxml.parts.document import CT_Body, CT_Document -register_element_cls('w:body', CT_Body) +register_element_cls('w:body', CT_Body) register_element_cls('w:document', CT_Document) from docx.oxml.parts.numbering import ( CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr ) 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: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) from docx.oxml.parts.styles import CT_Style, CT_Styles -register_element_cls('w:style', CT_Style) +register_element_cls('w:style', CT_Style) register_element_cls('w:styles', CT_Styles) from docx.oxml.table import CT_Row, CT_Tbl, CT_TblGrid, CT_TblPr, CT_Tc -register_element_cls('w:tbl', CT_Tbl) -register_element_cls('w:tblGrid', CT_TblGrid) -register_element_cls('w:tblPr', CT_TblPr) +register_element_cls('w:tbl', CT_Tbl) +register_element_cls('w:tblGrid', CT_TblGrid) +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:tr', CT_Row) +register_element_cls('w:tc', CT_Tc) +register_element_cls('w:tr', CT_Row) from docx.oxml.text import ( CT_Br, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text, CT_Underline ) -register_element_cls('w:b', CT_OnOff) -register_element_cls('w:bCs', CT_OnOff) -register_element_cls('w:br', CT_Br) -register_element_cls('w:caps', CT_OnOff) -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: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:p', CT_P) -register_element_cls('w:pPr', CT_PPr) -register_element_cls('w:pStyle', CT_String) -register_element_cls('w:r', CT_R) -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:b', CT_OnOff) +register_element_cls('w:bCs', CT_OnOff) +register_element_cls('w:br', CT_Br) +register_element_cls('w:caps', CT_OnOff) +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: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:p', CT_P) +register_element_cls('w:pPr', CT_PPr) +register_element_cls('w:pStyle', CT_String) +register_element_cls('w:r', CT_R) +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:t', CT_Text) -register_element_cls('w:u', CT_Underline) -register_element_cls('w:vanish', CT_OnOff) -register_element_cls('w:webHidden', CT_OnOff) +register_element_cls('w:strike', CT_OnOff) +register_element_cls('w:t', CT_Text) +register_element_cls('w:u', CT_Underline) +register_element_cls('w:vanish', CT_OnOff) +register_element_cls('w:webHidden', CT_OnOff) diff --git a/docx/oxml/exceptions.py b/docx/oxml/exceptions.py index 0f5dbb1f0..4696f1e93 100644 --- a/docx/oxml/exceptions.py +++ b/docx/oxml/exceptions.py @@ -5,7 +5,11 @@ """ -class ValidationError(Exception): +class XmlchemyError(Exception): + """Generic error class.""" + + +class InvalidXmlError(XmlchemyError): """ Raised when invalid XML is encountered, such as on attempt to access a missing required child element diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index 747a8eba0..28e18eb18 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -7,7 +7,7 @@ from __future__ import absolute_import from . import OxmlElement -from .exceptions import ValidationError +from .exceptions import InvalidXmlError from .ns import qn from .xmlchemy import BaseOxmlElement @@ -54,7 +54,7 @@ def val(self): return False elif val in ('1', 'true', 'on'): return True - raise ValidationError("expected xsd:boolean, got '%s'" % val) + raise InvalidXmlError("expected xsd:boolean, got '%s'" % val) @val.setter def val(self, value): diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 53689cf5c..d57f71a01 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals from . import OxmlElement -from .exceptions import ValidationError +from .exceptions import InvalidXmlError from .ns import qn from .shared import CT_String from .text import CT_P @@ -77,14 +77,14 @@ def new(cls): def tblGrid(self): tblGrid = self.find(qn('w:tblGrid')) if tblGrid is None: - raise ValidationError('required w:tblGrid child not found') + raise InvalidXmlError('required w:tblGrid child not found') return tblGrid @property def tblPr(self): tblPr = self.find(qn('w:tblPr')) if tblPr is None: - raise ValidationError('required w:tblPr child not found') + raise InvalidXmlError('required w:tblPr child not found') return tblPr @property diff --git a/docx/oxml/xmlchemy.py b/docx/oxml/xmlchemy.py index e9aeb0c96..06df9fbfa 100644 --- a/docx/oxml/xmlchemy.py +++ b/docx/oxml/xmlchemy.py @@ -13,7 +13,7 @@ from . import OxmlElement from ..compat import Unicode -from ..exc import InvalidXmlError +from .exceptions import InvalidXmlError from .ns import NamespacePrefixedTag, nsmap, qn from ..shared import lazyproperty diff --git a/tests/oxml/test_xmlchemy.py b/tests/oxml/test_xmlchemy.py index 356428ed2..231ee19f3 100644 --- a/tests/oxml/test_xmlchemy.py +++ b/tests/oxml/test_xmlchemy.py @@ -9,8 +9,8 @@ import pytest from docx.compat import Unicode -from docx.exc import InvalidXmlError from docx.oxml import parse_xml, register_element_cls +from docx.oxml.exceptions import InvalidXmlError from docx.oxml.ns import qn from docx.oxml.simpletypes import BaseIntType from docx.oxml.xmlchemy import ( From e9e93a6a92764dbbebd9e5ce51b07dfa96954e5c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 22:10:11 -0700 Subject: [PATCH 044/809] oxml: convert CT_Document to xmlchemy --- docx/oxml/parts/document.py | 6 ++---- tests/oxml/parts/test_document.py | 10 +--------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/docx/oxml/parts/document.py b/docx/oxml/parts/document.py index 1773680f6..188b5d4e3 100644 --- a/docx/oxml/parts/document.py +++ b/docx/oxml/parts/document.py @@ -8,16 +8,14 @@ from ..ns import qn from ..table import CT_Tbl from ..text import CT_P -from ..xmlchemy import BaseOxmlElement +from ..xmlchemy import BaseOxmlElement, ZeroOrOne class CT_Document(BaseOxmlElement): """ ```` element, the root element of a document.xml file. """ - @property - def body(self): - return self.find(qn('w:body')) + body = ZeroOrOne('w:body') class CT_Body(BaseOxmlElement): diff --git a/tests/oxml/parts/test_document.py b/tests/oxml/parts/test_document.py index 2a710296e..eb3696ffe 100644 --- a/tests/oxml/parts/test_document.py +++ b/tests/oxml/parts/test_document.py @@ -4,10 +4,9 @@ Test suite for the docx.oxml.parts module. """ -from docx.oxml.parts.document import CT_Body from docx.oxml.text import CT_P -from .unitdata.document import a_body, a_document +from .unitdata.document import a_body from ..unitdata.text import a_p, a_sectPr @@ -52,10 +51,3 @@ def it_can_clear_all_the_content_it_holds(self): body.clear_content() # verify ------------------- assert body.xml == after_body_bldr.xml() - - -class DescribeCT_Document(object): - - def it_holds_a_body_element(self): - document = a_document().with_nsdecls().with_child(a_body()).element - assert isinstance(document.body, CT_Body) From 564d512320b6887d196de3f8cfff808ccd02e3a3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 22:50:30 -0700 Subject: [PATCH 045/809] oxml: convert CT_Body to xmlchemy --- docs/dev/analysis/schema/ct_body.rst | 86 ++++++++++++++++++++-------- docx/oxml/parts/document.py | 31 +++++----- 2 files changed, 75 insertions(+), 42 deletions(-) diff --git a/docs/dev/analysis/schema/ct_body.rst b/docs/dev/analysis/schema/ct_body.rst index 394aa33e2..d098b6660 100644 --- a/docs/dev/analysis/schema/ct_body.rst +++ b/docs/dev/analysis/schema/ct_body.rst @@ -45,6 +45,44 @@ Schema excerpt :: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -63,31 +101,45 @@ Schema excerpt - - + + - + - - - - - + + + + + + + + + + + + + + + + + + + - - + + @@ -111,17 +163,3 @@ Schema excerpt - - - - - - - - - - - - - - diff --git a/docx/oxml/parts/document.py b/docx/oxml/parts/document.py index 188b5d4e3..167c42ad1 100644 --- a/docx/oxml/parts/document.py +++ b/docx/oxml/parts/document.py @@ -7,8 +7,7 @@ from ..ns import qn from ..table import CT_Tbl -from ..text import CT_P -from ..xmlchemy import BaseOxmlElement, ZeroOrOne +from ..xmlchemy import BaseOxmlElement, ZeroOrOne, ZeroOrMore class CT_Document(BaseOxmlElement): @@ -23,12 +22,17 @@ 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',)) + def add_p(self): """ Return a new element that has been added at the end of any existing body content. """ - p = CT_P.new() + return self._add_p() + + def _insert_p(self, p): return self._append_blocklevelelt(p) def add_tbl(self): @@ -36,9 +40,14 @@ def add_tbl(self): Return a new element that has been added at the end of any existing body content. """ - tbl = CT_Tbl.new() + return self._add_tbl() + + def _insert_tbl(self, tbl): return self._append_blocklevelelt(tbl) + def _new_tbl(self): + return CT_Tbl.new() + def clear_content(self): """ Remove all content child elements from this element. Leave @@ -51,20 +60,6 @@ def clear_content(self): for content_elm in content_elms: self.remove(content_elm) - @property - def p_lst(self): - """ - List of child elements. - """ - return self.findall(qn('w:p')) - - @property - def tbl_lst(self): - """ - List of child elements. - """ - return self.findall(qn('w:tbl')) - def _append_blocklevelelt(self, block_level_elt): """ Return *block_level_elt* after appending it to end of From e17d3714666b89f665f3a81785a014a7b9a571a1 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 15 Jun 2014 22:58:01 -0700 Subject: [PATCH 046/809] xmlch: add public adder for ZeroOrMore --- docx/oxml/parts/document.py | 14 ---------- docx/oxml/xmlchemy.py | 51 +++++++++++++++++++------------------ tests/oxml/test_xmlchemy.py | 9 +++++++ 3 files changed, 35 insertions(+), 39 deletions(-) diff --git a/docx/oxml/parts/document.py b/docx/oxml/parts/document.py index 167c42ad1..3588fe6df 100644 --- a/docx/oxml/parts/document.py +++ b/docx/oxml/parts/document.py @@ -25,23 +25,9 @@ class CT_Body(BaseOxmlElement): p = ZeroOrMore('w:p', successors=('w:sectPr',)) tbl = ZeroOrMore('w:tbl', successors=('w:sectPr',)) - def add_p(self): - """ - Return a new element that has been added at the end of any - existing body content. - """ - return self._add_p() - def _insert_p(self, p): return self._append_blocklevelelt(p) - def add_tbl(self): - """ - Return a new element that has been added at the end of any - existing body content. - """ - return self._add_tbl() - def _insert_tbl(self, tbl): return self._append_blocklevelelt(tbl) diff --git a/docx/oxml/xmlchemy.py b/docx/oxml/xmlchemy.py index 06df9fbfa..02f452062 100644 --- a/docx/oxml/xmlchemy.py +++ b/docx/oxml/xmlchemy.py @@ -332,6 +332,21 @@ def _add_list_getter(self): def _add_method_name(self): 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 + ) + self._add_to_class(self._public_add_method_name, add_child) + def _add_to_class(self, name, method): """ Add *method* to the target class as *name*, unless *name* is already @@ -384,6 +399,16 @@ def get_child_element_list(obj): ) return get_child_element_list + @lazyproperty + def _public_add_method_name(self): + """ + add_childElement() is public API for a repeating element, allowing + new elements to be added to the sequence. May be overridden to + provide a friendlier API to clients having domain appropriate + parameter names for required attributes. + """ + return 'add_%s' % self._prop_name + @lazyproperty def _remove_method_name(self): return '_remove_%s' % self._prop_name @@ -519,31 +544,6 @@ def populate_class_members(self, element_cls, prop_name): self._add_public_adder() delattr(element_cls, 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 - ) - self._add_to_class(self._public_add_method_name, add_child) - - @lazyproperty - def _public_add_method_name(self): - """ - add_childElement() is public API for a repeating element, allowing - new elements to be added to the sequence. May be overridden to - provide a friendlier API to clients having domain appropriate - parameter names for required attributes. - """ - return 'add_%s' % self._prop_name - class ZeroOrMore(_BaseChildElement): """ @@ -560,6 +560,7 @@ def populate_class_members(self, element_cls, prop_name): self._add_creator() self._add_inserter() self._add_adder() + self._add_public_adder() delattr(element_cls, prop_name) diff --git a/tests/oxml/test_xmlchemy.py b/tests/oxml/test_xmlchemy.py index 231ee19f3..59a730d26 100644 --- a/tests/oxml/test_xmlchemy.py +++ b/tests/oxml/test_xmlchemy.py @@ -527,6 +527,15 @@ def it_adds_an_add_method_for_the_child_element(self, add_fixture): 'Add a new ```` child element ' ) + def it_adds_a_public_add_method_for_the_child_element(self, add_fixture): + parent, expected_xml = add_fixture + zomChild = parent.add_zomChild() + assert parent.xml == expected_xml + assert isinstance(zomChild, CT_ZomChild) + assert parent._add_zomChild.__doc__.startswith( + 'Add a new ```` child element ' + ) + def it_removes_the_property_root_name_used_for_declaration(self): assert not hasattr(CT_Parent, 'zomChild') From 14928c63947237fa1bd9d55e1bf3c644262f45ca Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 00:02:35 -0700 Subject: [PATCH 047/809] oxml: convert CT_Num to xmlchemy Organize tests/parts/test_numbering.py a bit. --- docs/dev/analysis/features/numbering.rst | 55 ++++++++++++++++++++++++ docs/dev/analysis/index.rst | 1 + docx/oxml/parts/numbering.py | 35 ++++++--------- docx/oxml/simpletypes.py | 4 ++ tests/parts/test_numbering.py | 40 +++++++++-------- 5 files changed, 94 insertions(+), 41 deletions(-) create mode 100644 docs/dev/analysis/features/numbering.rst diff --git a/docs/dev/analysis/features/numbering.rst b/docs/dev/analysis/features/numbering.rst new file mode 100644 index 000000000..837cf0e9b --- /dev/null +++ b/docs/dev/analysis/features/numbering.rst @@ -0,0 +1,55 @@ + +Numbering Part +============== + +... having to do with numbering sequences for ordered lists, etc. ... + + +Schema excerpt +-------------- + +.. highlight:: xml + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index 2b1225c6d..e3b6f2efe 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,7 @@ Feature Analysis .. toctree:: :maxdepth: 1 + features/numbering features/underline features/char-style features/breaks diff --git a/docx/oxml/parts/numbering.py b/docx/oxml/parts/numbering.py index 2f7c6cc29..7d3cb3b2a 100644 --- a/docx/oxml/parts/numbering.py +++ b/docx/oxml/parts/numbering.py @@ -5,9 +5,12 @@ """ from .. import OxmlElement -from ..shared import CT_DecimalNumber from ..ns import qn -from ..xmlchemy import BaseOxmlElement +from ..shared import CT_DecimalNumber +from ..simpletypes import ST_DecimalNumber +from ..xmlchemy import ( + BaseOxmlElement, OneAndOnlyOne, RequiredAttribute, ZeroOrMore +) class CT_Num(BaseOxmlElement): @@ -16,18 +19,16 @@ class CT_Num(BaseOxmlElement): instance, having a required child that references an abstract numbering definition that defines most of the formatting details. """ - @property - def abstractNumId(self): - return self.find(qn('w:abstractNumId')) + abstractNumId = OneAndOnlyOne('w:abstractNumId') + lvlOverride = ZeroOrMore('w:lvlOverride') + numId = RequiredAttribute('w:numId', ST_DecimalNumber) def add_lvlOverride(self, ilvl): """ Return a newly added CT_NumLvl () element having its ``ilvl`` attribute set to *ilvl*. """ - lvlOverride = CT_NumLvl.new(ilvl) - self.append(lvlOverride) - return lvlOverride + return self._add_lvlOverride(ilvl=ilvl) @classmethod def new(cls, num_id, abstractNum_id): @@ -36,24 +37,22 @@ def new(cls, num_id, abstractNum_id): a ```` child with val attribute set to *abstractNum_id*. """ + num = OxmlElement('w:num') + num.numId = num_id abstractNumId = CT_DecimalNumber.new( 'w:abstractNumId', abstractNum_id ) - num = OxmlElement('w:num', {qn('w:numId'): str(num_id)}) num.append(abstractNumId) return num - @property - def numId(self): - numId_str = self.get(qn('w:numId')) - return int(numId_str) - class CT_NumLvl(BaseOxmlElement): """ ```` element, which identifies a level in a list definition to override with settings it contains. """ + ilvl = RequiredAttribute('w:ilvl', ST_DecimalNumber) + def add_startOverride(self, val): """ Return a newly added CT_DecimalNumber element having tagname @@ -63,14 +62,6 @@ def add_startOverride(self, val): self.insert(0, startOverride) return startOverride - @classmethod - def new(cls, ilvl): - """ - Return a new ```` element having its ``ilvl`` - attribute set to *ilvl*. - """ - return OxmlElement('w:lvlOverride', {qn('w:ilvl'): str(ilvl)}) - class CT_NumPr(BaseOxmlElement): """ diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 54183871d..951df6041 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -149,3 +149,7 @@ class XsdUnsignedInt(BaseIntType): @classmethod def validate(cls, value): cls.validate_int_in_range(value, 0, 4294967295) + + +class ST_DecimalNumber(XsdInt): + pass diff --git a/tests/parts/test_numbering.py b/tests/parts/test_numbering.py index c1ed98d31..257f6a756 100644 --- a/tests/parts/test_numbering.py +++ b/tests/parts/test_numbering.py @@ -62,10 +62,6 @@ def it_provides_access_to_the_numbering_definitions( # fixtures ------------------------------------------------------- - @pytest.fixture - def blob_(self, request): - return instance_mock(request, bytes) - @pytest.fixture def construct_fixture( self, partname_, content_type_, blob_, package_, parse_xml_, @@ -75,14 +71,6 @@ def construct_fixture( numbering_elm_ ) - @pytest.fixture - def content_type_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def init__(self, request): - return initializer_mock(request, NumberingPart) - @pytest.fixture def load_fixture( self, numbering_part_load_, partname_, blob_, package_, @@ -92,13 +80,6 @@ def load_fixture( numbering_part_load_, partname_, blob_, package_, numbering_part_ ) - @pytest.fixture - def _NumberingDefinitions_(self, request, numbering_definitions_): - return class_mock( - request, 'docx.parts.numbering._NumberingDefinitions', - return_value=numbering_definitions_ - ) - @pytest.fixture def num_defs_fixture( self, _NumberingDefinitions_, numbering_elm_, @@ -109,6 +90,27 @@ def num_defs_fixture( numbering_definitions_ ) + # fixture components --------------------------------------------- + + @pytest.fixture + def blob_(self, request): + return instance_mock(request, bytes) + + @pytest.fixture + def content_type_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def init__(self, request): + return initializer_mock(request, NumberingPart) + + @pytest.fixture + def _NumberingDefinitions_(self, request, numbering_definitions_): + return class_mock( + request, 'docx.parts.numbering._NumberingDefinitions', + return_value=numbering_definitions_ + ) + @pytest.fixture def numbering_definitions_(self, request): return instance_mock(request, _NumberingDefinitions) From 3af80a4ee1b56491c7b1aa324ea46e2a1781dc7a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 00:12:32 -0700 Subject: [PATCH 048/809] oxml: convert CT_NumLvl to xmlchemy --- docx/oxml/__init__.py | 1 + docx/oxml/parts/numbering.py | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 816ec3976..eb345d7a0 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -92,6 +92,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:numId', CT_DecimalNumber) register_element_cls('w:numPr', CT_NumPr) register_element_cls('w:numbering', CT_Numbering) +register_element_cls('w:startOverride', CT_DecimalNumber) from docx.oxml.parts.styles import CT_Style, CT_Styles register_element_cls('w:style', CT_Style) diff --git a/docx/oxml/parts/numbering.py b/docx/oxml/parts/numbering.py index 7d3cb3b2a..3a9a576bf 100644 --- a/docx/oxml/parts/numbering.py +++ b/docx/oxml/parts/numbering.py @@ -9,7 +9,7 @@ from ..shared import CT_DecimalNumber from ..simpletypes import ST_DecimalNumber from ..xmlchemy import ( - BaseOxmlElement, OneAndOnlyOne, RequiredAttribute, ZeroOrMore + BaseOxmlElement, OneAndOnlyOne, RequiredAttribute, ZeroOrMore, ZeroOrOne ) @@ -51,6 +51,7 @@ class CT_NumLvl(BaseOxmlElement): ```` element, which identifies a level in a list definition to override with settings it contains. """ + startOverride = ZeroOrOne('w:startOverride', successors=('w:lvl',)) ilvl = RequiredAttribute('w:ilvl', ST_DecimalNumber) def add_startOverride(self, val): @@ -58,9 +59,7 @@ def add_startOverride(self, val): Return a newly added CT_DecimalNumber element having tagname ``w:startOverride`` and ``val`` attribute set to *val*. """ - startOverride = CT_DecimalNumber.new('w:startOverride', val) - self.insert(0, startOverride) - return startOverride + return self._add_startOverride(val=val) class CT_NumPr(BaseOxmlElement): From 1e9c2446db0b52fb18f430ddb834842b5b8d5c42 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 00:49:59 -0700 Subject: [PATCH 049/809] oxml: convert CT_NumPr to xmlchemy --- docx/oxml/parts/numbering.py | 95 ++++++++---------------------------- docx/oxml/text.py | 3 +- 2 files changed, 22 insertions(+), 76 deletions(-) diff --git a/docx/oxml/parts/numbering.py b/docx/oxml/parts/numbering.py index 3a9a576bf..19dacf5ab 100644 --- a/docx/oxml/parts/numbering.py +++ b/docx/oxml/parts/numbering.py @@ -67,80 +67,27 @@ class CT_NumPr(BaseOxmlElement): A ```` element, a container for numbering properties applied to a paragraph. """ - def get_or_add_ilvl(self): - """ - Return the ilvl child element, newly added if not present. - """ - ilvl = self.ilvl - if ilvl is None: - ilvl = self._add_ilvl() - return ilvl - - def get_or_add_numId(self): - """ - Return the numId child element, newly added if not present. - """ - numId = self.numId - if numId is None: - numId = self._add_numId() - return numId - - @property - def ilvl(self): - return self.find(qn('w:ilvl')) - - @ilvl.setter - def ilvl(self, val): - """ - Get or add a child and set its ``w:val`` attribute to *val*. - """ - ilvl = self.get_or_add_ilvl() - ilvl.val = val - - @classmethod - def new(cls): - """ - Return a new ```` element - """ - return OxmlElement('w:numPr') - - @property - def numId(self): - return self.find(qn('w:numId')) - - @numId.setter - def numId(self, val): - """ - Get or add a child and set its ``w:val`` attribute to *val*. - """ - numId = self.get_or_add_numId() - numId.val = val - - def _add_ilvl(self, val=0): - """ - Return a newly added CT_DecimalNumber element having tagname 'w:ilvl' - and ``val`` attribute set to *val*. - """ - ilvl = CT_DecimalNumber.new('w:ilvl', val) - return self._insert_ilvl(ilvl) - - def _add_numId(self, val=0): - """ - Return a newly added CT_DecimalNumber element having tagname - 'w:numId' and ``val`` attribute set to *val*. - """ - numId = CT_DecimalNumber.new('w:numId', val) - return self._insert_numId(numId) - - def _insert_ilvl(self, ilvl): - return self.insert_element_before( - ilvl, 'w:numId', 'w:numberingChange', 'w:ins' - ) - - def _insert_numId(self, numId): - return self.insert_element_before( - numId, 'w:numberingChange', 'w:ins' - ) + ilvl = ZeroOrOne('w:ilvl', successors=( + 'w:numId', 'w:numberingChange', 'w:ins' + )) + numId = ZeroOrOne('w:numId', successors=('w:numberingChange', 'w:ins')) + + # @ilvl.setter + # def _set_ilvl(self, val): + # """ + # Get or add a child and set its ``w:val`` attribute to *val*. + # """ + # ilvl = self.get_or_add_ilvl() + # ilvl.val = val + + # @numId.setter + # def numId(self, val): + # """ + # Get or add a child and set its ``w:val`` attribute to + # *val*. + # """ + # numId = self.get_or_add_numId() + # numId.val = val class CT_Numbering(BaseOxmlElement): diff --git a/docx/oxml/text.py b/docx/oxml/text.py index fdb7bbad4..fec115095 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -8,7 +8,6 @@ from . import parse_xml, OxmlElement from ..enum.text import WD_UNDERLINE from .ns import nsdecls, qn -from .parts.numbering import CT_NumPr from .shared import CT_String from .xmlchemy import BaseOxmlElement @@ -190,7 +189,7 @@ def style(self, style): self.pStyle.val = style def _add_numPr(self): - numPr = CT_NumPr.new() + numPr = OxmlElement('w:numPr') return self._insert_numPr(numPr) def _add_pStyle(self, style): From d622225e74c12d61cf185de0be8f4dfd8e0fe22f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 01:14:51 -0700 Subject: [PATCH 050/809] oxml: convert CT_Numbering to xmlchemy --- docx/oxml/parts/numbering.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/docx/oxml/parts/numbering.py b/docx/oxml/parts/numbering.py index 19dacf5ab..31d97dbce 100644 --- a/docx/oxml/parts/numbering.py +++ b/docx/oxml/parts/numbering.py @@ -5,7 +5,6 @@ """ from .. import OxmlElement -from ..ns import qn from ..shared import CT_DecimalNumber from ..simpletypes import ST_DecimalNumber from ..xmlchemy import ( @@ -95,22 +94,17 @@ class CT_Numbering(BaseOxmlElement): ```` element, the root element of a numbering part, i.e. numbering.xml """ + num = ZeroOrMore('w:num', successors=('w:numIdMacAtCleanup',)) + def add_num(self, abstractNum_id): """ - Return a newly added CT_Num () element that references - the abstract numbering definition having id *abstractNum_id*. + Return a newly added CT_Num () element referencing the + abstract numbering definition identified by *abstractNum_id*. """ next_num_id = self._next_numId num = CT_Num.new(next_num_id, abstractNum_id) return self._insert_num(num) - @property - def num_lst(self): - """ - List of child elements. - """ - return self.findall(qn('w:num')) - def num_having_numId(self, numId): """ Return the ```` child element having ``numId`` attribute @@ -122,9 +116,6 @@ def num_having_numId(self, numId): except IndexError: raise KeyError('no element with numId %d' % numId) - def _insert_num(self, num): - return self.insert_element_before(num, 'w:numIdMacAtCleanup') - @property def _next_numId(self): """ From 032755852a8e960845799acef682b2c9bf710dbc Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 01:26:37 -0700 Subject: [PATCH 051/809] oxml: convert CT_Style to xmlchemy --- docs/dev/analysis/schema/ct_styles.rst | 52 +++++++++----------------- docx/oxml/parts/styles.py | 8 ++-- 2 files changed, 21 insertions(+), 39 deletions(-) diff --git a/docs/dev/analysis/schema/ct_styles.rst b/docs/dev/analysis/schema/ct_styles.rst index 5c61f904a..1977acc8a 100644 --- a/docs/dev/analysis/schema/ct_styles.rst +++ b/docs/dev/analysis/schema/ct_styles.rst @@ -1,6 +1,6 @@ -############# + ``CT_Styles`` -############# +============= .. highlight:: xml @@ -17,7 +17,7 @@ Analysis -======== +-------- Only styles with an explicit ```` definition affect the formatting of paragraphs that are assigned that style. @@ -34,26 +34,8 @@ is no longer used by any paragraphs. The definition of each of the styles ever used in a document are accumulated in ``styles.xml``. -attributes -^^^^^^^^^^ - -None. - - -child elements -^^^^^^^^^^^^^^ - -============ ==== ================ -name # type -============ ==== ================ -docDefaults ? CT_DocDefaults -latentStyles ? CT_LatentStyles -style \* CT_TextParagraph -============ ==== ================ - - Spec text -^^^^^^^^^ +--------- This element specifies all of the style information stored in the WordprocessingML document: style definitions as well as latent style @@ -66,14 +48,14 @@ Spec text Schema excerpt -^^^^^^^^^^^^^^ +-------------- :: - + @@ -99,7 +81,7 @@ Schema excerpt - + @@ -115,21 +97,21 @@ Schema excerpt - - - - - + + + + + - - - - + + + + - + diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index b8fcb61e3..d26b6dcfc 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -5,16 +5,16 @@ """ from ..ns import qn -from ..xmlchemy import BaseOxmlElement +from ..xmlchemy import BaseOxmlElement, ZeroOrOne class CT_Style(BaseOxmlElement): """ A ```` element, representing a style definition """ - @property - def pPr(self): - return self.find(qn('w:pPr')) + pPr = ZeroOrOne('w:pPr', successors=( + 'w:rPr', 'w:tblPr', 'w:trPr', 'w:tcPr', 'w:tblStylePr' + )) class CT_Styles(BaseOxmlElement): From 82c080b4181a4a7de0ebc63276e4c8efa8766704 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 01:30:49 -0700 Subject: [PATCH 052/809] oxml: convert CT_Styles to xmlchemy --- docx/oxml/parts/styles.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index d26b6dcfc..ed3054f13 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -4,8 +4,7 @@ Custom element classes related to the styles part """ -from ..ns import qn -from ..xmlchemy import BaseOxmlElement, ZeroOrOne +from ..xmlchemy import BaseOxmlElement, ZeroOrMore, ZeroOrOne class CT_Style(BaseOxmlElement): @@ -22,6 +21,8 @@ class CT_Styles(BaseOxmlElement): ```` element, the root element of a styles part, i.e. styles.xml """ + style = ZeroOrMore('w:style', successors=()) + def style_having_styleId(self, styleId): """ Return the ```` child element having ``styleId`` attribute @@ -32,10 +33,3 @@ def style_having_styleId(self, styleId): return self.xpath(xpath)[0] except IndexError: raise KeyError('no element with styleId %d' % styleId) - - @property - def style_lst(self): - """ - List of child elements. - """ - return self.findall(qn('w:style')) From 3180a992d6e09cae520b26d3e91220047f874e8c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 01:55:54 -0700 Subject: [PATCH 053/809] oxml: convert CT_Blip to xmlchemy --- docs/dev/analysis/features/picture.rst | 44 +++++++++++++------------- docx/oxml/shape.py | 12 +++---- docx/oxml/simpletypes.py | 4 +++ 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/docs/dev/analysis/features/picture.rst b/docs/dev/analysis/features/picture.rst index 06c58c357..dde1af056 100644 --- a/docs/dev/analysis/features/picture.rst +++ b/docs/dev/analysis/features/picture.rst @@ -114,12 +114,7 @@ Schema definitions - - - - - - + @@ -142,18 +137,23 @@ Schema definitions - - + + + + + + + - - + + @@ -164,9 +164,9 @@ Schema definitions - - - + + + @@ -174,7 +174,7 @@ Schema definitions - + @@ -202,10 +202,10 @@ Schema definitions - - - - + + + + @@ -219,7 +219,7 @@ Schema definitions - + @@ -233,9 +233,9 @@ Schema definitions - - - + + + diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index 5c18694d5..bcaface5c 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -7,7 +7,8 @@ from . import OxmlElement from ..shared import Emu from .ns import nsmap, nspfxmap, qn -from .xmlchemy import BaseOxmlElement +from .simpletypes import ST_RelationshipId +from .xmlchemy import BaseOxmlElement, OptionalAttribute class CT_Blip(BaseOxmlElement): @@ -15,13 +16,8 @@ class CT_Blip(BaseOxmlElement): ```` element, specifies image source and adjustments such as alpha and tint. """ - @property - def embed(self): - return self.get(qn('r:embed')) - - @property - def link(self): - return self.get(qn('r:link')) + embed = OptionalAttribute('r:embed', ST_RelationshipId) + link = OptionalAttribute('r:link', ST_RelationshipId) @classmethod def new(cls, rId): diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 951df6041..73246a4ef 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -153,3 +153,7 @@ def validate(cls, value): class ST_DecimalNumber(XsdInt): pass + + +class ST_RelationshipId(XsdString): + pass From 504065af7e2f60df97949f11e9f6ffa209a6d2cb Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 02:05:56 -0700 Subject: [PATCH 054/809] oxml: convert CT_BlipFillProperties to xmlchemy --- docs/dev/analysis/features/picture.rst | 65 ++++++++++++++------------ docx/oxml/shape.py | 8 ++-- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/docs/dev/analysis/features/picture.rst b/docs/dev/analysis/features/picture.rst index dde1af056..e98fed4bc 100644 --- a/docs/dev/analysis/features/picture.rst +++ b/docs/dev/analysis/features/picture.rst @@ -114,6 +114,40 @@ Schema definitions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -146,16 +180,6 @@ Schema definitions - - - - - - - - - - @@ -177,13 +201,6 @@ Schema definitions - - - - - - - @@ -208,20 +225,6 @@ Schema definitions - - - - - - - - - - - - - - diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index bcaface5c..0af5de3e5 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -8,7 +8,7 @@ from ..shared import Emu from .ns import nsmap, nspfxmap, qn from .simpletypes import ST_RelationshipId -from .xmlchemy import BaseOxmlElement, OptionalAttribute +from .xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrOne class CT_Blip(BaseOxmlElement): @@ -30,9 +30,9 @@ class CT_BlipFillProperties(BaseOxmlElement): """ ```` element, specifies picture properties """ - @property - def blip(self): - return self.find(qn('a:blip')) + blip = ZeroOrOne('a:blip', successors=( + 'a:srcRect', 'a:tile', 'a:stretch' + )) @classmethod def new(cls, rId): From b821d1dc4999c665b7a0def02251c32bc2bce652 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 02:10:34 -0700 Subject: [PATCH 055/809] oxml: convert CT_GraphicalObject to xmlchemy --- docx/oxml/shape.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index 0af5de3e5..d420f929b 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -8,7 +8,9 @@ from ..shared import Emu from .ns import nsmap, nspfxmap, qn from .simpletypes import ST_RelationshipId -from .xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrOne +from .xmlchemy import ( + BaseOxmlElement, OneAndOnlyOne, OptionalAttribute, ZeroOrOne +) class CT_Blip(BaseOxmlElement): @@ -46,9 +48,7 @@ class CT_GraphicalObject(BaseOxmlElement): """ ```` element, container for a DrawingML object """ - @property - def graphicData(self): - return self.find(qn('a:graphicData')) + graphicData = OneAndOnlyOne('a:graphicData') @classmethod def new(cls, uri, pic): From 0087e2dc2f34e5b23188ffe6f94281e244bf1af7 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 02:17:33 -0700 Subject: [PATCH 056/809] oxml: convert CT_GraphicalObjectData to xmlchemy --- docx/oxml/shape.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index d420f929b..ef06fc28a 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -7,9 +7,10 @@ from . import OxmlElement from ..shared import Emu from .ns import nsmap, nspfxmap, qn -from .simpletypes import ST_RelationshipId +from .simpletypes import ST_RelationshipId, XsdToken from .xmlchemy import ( - BaseOxmlElement, OneAndOnlyOne, OptionalAttribute, ZeroOrOne + BaseOxmlElement, OneAndOnlyOne, OptionalAttribute, RequiredAttribute, + ZeroOrOne ) @@ -61,21 +62,16 @@ class CT_GraphicalObjectData(BaseOxmlElement): """ ```` element, container for the XML of a DrawingML object """ + pic = ZeroOrOne('pic:pic') + uri = RequiredAttribute('uri', XsdToken) + @classmethod def new(cls, uri, pic): graphicData = OxmlElement('a:graphicData') - graphicData.set('uri', uri) - graphicData.append(pic) + graphicData.uri = uri + graphicData._insert_pic(pic) return graphicData - @property - def pic(self): - return self.find(qn('pic:pic')) - - @property - def uri(self): - return self.get('uri') - class CT_Inline(BaseOxmlElement): """ From 0af022ab221cf23aceb7ab11a528b9c10865a1bb Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 02:22:13 -0700 Subject: [PATCH 057/809] oxml: convert CT_Inline to xmlchemy --- docx/oxml/shape.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index ef06fc28a..85ebe8cde 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -77,13 +77,8 @@ class CT_Inline(BaseOxmlElement): """ ```` element, container for an inline shape. """ - @property - def extent(self): - return self.find(qn('wp:extent')) - - @property - def graphic(self): - return self.find(qn('a:graphic')) + extent = OneAndOnlyOne('wp:extent') + graphic = OneAndOnlyOne('a:graphic') @classmethod def new(cls, cx, cy, shape_id, pic): From 7f97f7b1e5ed33ea4d336cb48a5ea2fe669e8fc9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 18:15:56 -0700 Subject: [PATCH 058/809] oxml: convert CT_Picture to xmlchemy --- docs/dev/analysis/features/shapes-inline.rst | 16 ++++++++-------- docx/oxml/shape.py | 12 +++++------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/docs/dev/analysis/features/shapes-inline.rst b/docs/dev/analysis/features/shapes-inline.rst index 30898e7a3..c6e952dfb 100644 --- a/docs/dev/analysis/features/shapes-inline.rst +++ b/docs/dev/analysis/features/shapes-inline.rst @@ -144,15 +144,15 @@ Schema definitions - + - - - - + + + + @@ -175,9 +175,9 @@ Schema definitions - - - + + + diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index 85ebe8cde..f8300a3ed 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -105,10 +105,10 @@ class CT_NonVisualDrawingProps(BaseOxmlElement): """ @classmethod def new(cls, nsptagname_str, shape_id, name): - elt = OxmlElement(nsptagname_str) - elt.set('id', str(shape_id)) - elt.set('name', name) - return elt + elm = OxmlElement(nsptagname_str) + elm.set('id', str(shape_id)) + elm.set('name', name) + return elm class CT_NonVisualPictureProperties(BaseOxmlElement): @@ -125,9 +125,7 @@ class CT_Picture(BaseOxmlElement): """ ```` element, a DrawingML picture """ - @property - def blipFill(self): - return self.find(qn('pic:blipFill')) + blipFill = OneAndOnlyOne('pic:blipFill') @classmethod def new(cls, pic_id, filename, rId, cx, cy): From 9b3e615ba03718d28d5ca776d73a90b7a7f668e2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 18:33:11 -0700 Subject: [PATCH 059/809] oxml: convert CT_PositiveSize2D to xmlchemy --- docx/oxml/__init__.py | 1 + docx/oxml/shape.py | 30 +++++------------------------- docx/oxml/simpletypes.py | 13 +++++++++++++ 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index eb345d7a0..0839cff95 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -71,6 +71,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): CT_GraphicalObjectData, CT_Inline, CT_Picture, CT_PositiveSize2D ) 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('pic:blipFill', CT_BlipFillProperties) diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index f8300a3ed..c9540526f 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -5,9 +5,8 @@ """ from . import OxmlElement -from ..shared import Emu from .ns import nsmap, nspfxmap, qn -from .simpletypes import ST_RelationshipId, XsdToken +from .simpletypes import ST_PositiveCoordinate, ST_RelationshipId, XsdToken from .xmlchemy import ( BaseOxmlElement, OneAndOnlyOne, OptionalAttribute, RequiredAttribute, ZeroOrOne @@ -173,33 +172,14 @@ class CT_PositiveSize2D(BaseOxmlElement): Used for ```` element, and perhaps others later. Specifies the size of a DrawingML drawing. """ - @property - def cx(self): - cx_str = self.get('cx') - cx = int(cx_str) - return Emu(cx) - - @cx.setter - def cx(self, cx): - cx_str = str(cx) - self.set('cx', cx_str) - - @property - def cy(self): - cy_str = self.get('cy') - cy = int(cy_str) - return Emu(cy) - - @cy.setter - def cy(self, cy): - cy_str = str(cy) - self.set('cy', cy_str) + cx = RequiredAttribute('cx', ST_PositiveCoordinate) + cy = RequiredAttribute('cy', ST_PositiveCoordinate) @classmethod def new(cls, nsptagname_str, cx, cy): elm = OxmlElement(nsptagname_str) - elm.set('cx', str(cx)) - elm.set('cy', str(cy)) + elm.cx = cx + elm.cy = cy return elm diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 73246a4ef..b73441ace 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -8,6 +8,8 @@ from __future__ import absolute_import, print_function +from ..shared import Emu + class BaseSimpleType(object): @@ -155,5 +157,16 @@ class ST_DecimalNumber(XsdInt): pass +class ST_PositiveCoordinate(XsdLong): + + @classmethod + def convert_from_xml(cls, str_value): + return Emu(int(str_value)) + + @classmethod + def validate(cls, value): + cls.validate_int_in_range(value, 0, 27273042316900) + + class ST_RelationshipId(XsdString): pass From 1eae1104e709fbab463dd06fdfc715f0603cdf7b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 18:41:55 -0700 Subject: [PATCH 060/809] oxml: convert CT_Point2D to xmlchemy --- docx/oxml/__init__.py | 4 +++- docx/oxml/shape.py | 11 ++++++++--- docx/oxml/simpletypes.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 0839cff95..bb6dca7b2 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -68,12 +68,14 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): from docx.oxml.shape import ( CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, - CT_GraphicalObjectData, CT_Inline, CT_Picture, CT_PositiveSize2D + CT_GraphicalObjectData, CT_Inline, CT_Picture, CT_Point2D, + CT_PositiveSize2D ) 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('pic:blipFill', CT_BlipFillProperties) register_element_cls('pic:pic', CT_Picture) register_element_cls('wp:extent', CT_PositiveSize2D) diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index c9540526f..9d07bd4b8 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -6,7 +6,9 @@ from . import OxmlElement from .ns import nsmap, nspfxmap, qn -from .simpletypes import ST_PositiveCoordinate, ST_RelationshipId, XsdToken +from .simpletypes import ( + ST_Coordinate, ST_PositiveCoordinate, ST_RelationshipId, XsdToken +) from .xmlchemy import ( BaseOxmlElement, OneAndOnlyOne, OptionalAttribute, RequiredAttribute, ZeroOrOne @@ -159,11 +161,14 @@ class CT_Point2D(BaseOxmlElement): Used for ```` element, and perhaps others. Specifies an x, y coordinate (point). """ + x = RequiredAttribute('x', ST_Coordinate) + y = RequiredAttribute('y', ST_Coordinate) + @classmethod def new(cls, nsptagname_str, x, y): elm = OxmlElement(nsptagname_str) - elm.set('x', str(x)) - elm.set('y', str(y)) + elm.x = x + elm.y = y return elm diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index b73441ace..990522c26 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -153,6 +153,26 @@ def validate(cls, value): cls.validate_int_in_range(value, 0, 4294967295) +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: + return ST_UniversalMeasure.convert_from_xml(str_value) + return int(str_value) + + @classmethod + def validate(cls, value): + ST_CoordinateUnqualified.validate(value) + + +class ST_CoordinateUnqualified(XsdLong): + + @classmethod + def validate(cls, value): + cls.validate_int_in_range(value, -27273042329600, 27273042316900) + + class ST_DecimalNumber(XsdInt): pass @@ -170,3 +190,17 @@ def validate(cls, value): class ST_RelationshipId(XsdString): pass + + +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 + }[units_part] + emu_value = int(round(quantity * multiplier)) + return emu_value From 22af2b04b40c21dc24801b74f6f9df4a20860f20 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 21:14:45 -0700 Subject: [PATCH 061/809] oxml: convert CT_DecimalNumber to xmlchemy --- docx/oxml/shared.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index 28e18eb18..9af3cdd0e 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -9,7 +9,8 @@ from . import OxmlElement from .exceptions import InvalidXmlError from .ns import qn -from .xmlchemy import BaseOxmlElement +from .simpletypes import ST_DecimalNumber +from .xmlchemy import BaseOxmlElement, RequiredAttribute class CT_DecimalNumber(BaseOxmlElement): @@ -18,6 +19,8 @@ class CT_DecimalNumber(BaseOxmlElement): others, containing a text representation of a decimal number (e.g. 42) in its ``val`` attribute. """ + val = RequiredAttribute('w:val', ST_DecimalNumber) + @classmethod def new(cls, nsptagname, val): """ @@ -26,19 +29,6 @@ def new(cls, nsptagname, val): """ return OxmlElement(nsptagname, attrs={qn('w:val'): str(val)}) - @property - def val(self): - """ - Required attribute containing a decimal integer - """ - number_str = self.get(qn('w:val')) - return int(number_str) - - @val.setter - def val(self, val): - decimal_number_str = '%d' % val - self.set(qn('w:val'), decimal_number_str) - class CT_OnOff(BaseOxmlElement): """ From e77bbdde53a688ed32ae26e2ba2c0e58d003ded4 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 21:37:13 -0700 Subject: [PATCH 062/809] oxml: convert CT_OnOff to xmlchemy --- docx/oxml/shared.py | 25 +++---------------------- docx/oxml/simpletypes.py | 7 +++++++ 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index 9af3cdd0e..c5eb66ea7 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -7,10 +7,9 @@ from __future__ import absolute_import from . import OxmlElement -from .exceptions import InvalidXmlError from .ns import qn -from .simpletypes import ST_DecimalNumber -from .xmlchemy import BaseOxmlElement, RequiredAttribute +from .simpletypes import ST_DecimalNumber, ST_OnOff +from .xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute class CT_DecimalNumber(BaseOxmlElement): @@ -35,25 +34,7 @@ class CT_OnOff(BaseOxmlElement): Used for ````, ```` elements and others, containing a bool-ish string in its ``val`` attribute, xsd:boolean plus 'on' and 'off'. """ - @property - def val(self): - val = self.get(qn('w:val')) - if val is None: - return True - elif val in ('0', 'false', 'off'): - return False - elif val in ('1', 'true', 'on'): - return True - raise InvalidXmlError("expected xsd:boolean, got '%s'" % val) - - @val.setter - def val(self, value): - val = qn('w:val') - if bool(value) is True: - if val in self.attrib: - del self.attrib[val] - else: - self.set(val, '0') + val = OptionalAttribute('w:val', ST_OnOff, default=True) class CT_String(BaseOxmlElement): diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 990522c26..84c1baa76 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -177,6 +177,13 @@ class ST_DecimalNumber(XsdInt): pass +class ST_OnOff(XsdBoolean): + + @classmethod + def convert_from_xml(cls, str_value): + return str_value in ('1', 'true', 'on') + + class ST_PositiveCoordinate(XsdLong): @classmethod From d49c0f511e2f42998a5a49c78f57b65add23253c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 21:46:08 -0700 Subject: [PATCH 063/809] oxml: convert CT_String to xmlchemy --- docx/oxml/shared.py | 24 ++++++++++++------------ docx/oxml/simpletypes.py | 4 ++++ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index c5eb66ea7..904933630 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -8,7 +8,7 @@ from . import OxmlElement from .ns import qn -from .simpletypes import ST_DecimalNumber, ST_OnOff +from .simpletypes import ST_DecimalNumber, ST_OnOff, ST_String from .xmlchemy import BaseOxmlElement, OptionalAttribute, RequiredAttribute @@ -42,13 +42,17 @@ class CT_String(BaseOxmlElement): Used for ```` and ```` elements and others, containing a style name in its ``val`` attribute. """ + val = RequiredAttribute('w:val', ST_String) + @classmethod def new(cls, nsptagname, val): """ Return a new ``CT_String`` element with tagname *nsptagname* and ``val`` attribute set to *val*. """ - return OxmlElement(nsptagname, attrs={qn('w:val'): val}) + elm = OxmlElement(nsptagname) + elm.val = val + return elm @classmethod def new_pStyle(cls, val): @@ -56,7 +60,9 @@ def new_pStyle(cls, val): Return a new ```` element with ``val`` attribute set to *val*. """ - return OxmlElement('w:pStyle', attrs={qn('w:val'): val}) + pStyle = OxmlElement('w:pStyle') + pStyle.val = val + return pStyle @classmethod def new_rStyle(cls, val): @@ -64,12 +70,6 @@ def new_rStyle(cls, val): Return a new ```` element with ``val`` attribute set to *val*. """ - return OxmlElement('w:rStyle', attrs={qn('w:val'): val}) - - @property - def val(self): - return self.get(qn('w:val')) - - @val.setter - def val(self, val): - return self.set(qn('w:val'), val) + rStyle = OxmlElement('w:rStyle') + rStyle.val = val + return rStyle diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 84c1baa76..04fa5383e 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -199,6 +199,10 @@ class ST_RelationshipId(XsdString): pass +class ST_String(XsdString): + pass + + class ST_UniversalMeasure(BaseSimpleType): @classmethod From ce1711d7e58b387cbb65a9c4d2c3cfce916d0e4d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 21:55:37 -0700 Subject: [PATCH 064/809] oxml: convert CT_Row to xmlchemy --- docx/oxml/table.py | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index d57f71a01..197bd7202 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -11,20 +11,17 @@ from .ns import qn from .shared import CT_String from .text import CT_P -from .xmlchemy import BaseOxmlElement +from .xmlchemy import BaseOxmlElement, ZeroOrMore class CT_Row(BaseOxmlElement): """ ```` element """ - def add_tc(self): - """ - Return a new element that has been added at the end of any - existing tc elements. - """ - tc = CT_Tc.new() - return self._append_tc(tc) + tc = ZeroOrMore('w:tc') + + def _new_tc(self): + return CT_Tc.new() @classmethod def new(cls): @@ -33,20 +30,6 @@ def new(cls): """ return OxmlElement('w:tr') - @property - def tc_lst(self): - """ - Sequence containing the ```` child elements in this ````. - """ - return self.findall(qn('w:tc')) - - def _append_tc(self, tc): - """ - Return *tc* after appending it to end of tc sequence. - """ - self.append(tc) - return tc - class CT_Tbl(BaseOxmlElement): """ From a6e6d46cc661350b65b05444230c05f2207d822c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 22:19:27 -0700 Subject: [PATCH 065/809] oxml: convert CT_Tbl to xmlchemy --- docx/oxml/table.py | 49 ++++------------------------------------------ 1 file changed, 4 insertions(+), 45 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 197bd7202..ca41a4109 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -7,11 +7,10 @@ from __future__ import absolute_import, print_function, unicode_literals from . import OxmlElement -from .exceptions import InvalidXmlError from .ns import qn from .shared import CT_String from .text import CT_P -from .xmlchemy import BaseOxmlElement, ZeroOrMore +from .xmlchemy import BaseOxmlElement, OneAndOnlyOne, ZeroOrMore class CT_Row(BaseOxmlElement): @@ -23,25 +22,14 @@ class CT_Row(BaseOxmlElement): def _new_tc(self): return CT_Tc.new() - @classmethod - def new(cls): - """ - Return a new ```` element. - """ - return OxmlElement('w:tr') - class CT_Tbl(BaseOxmlElement): """ ```` element """ - def add_tr(self): - """ - Return a new element that has been added at the end of any - existing tr elements. - """ - tr = CT_Row.new() - return self._append_tr(tr) + tblPr = OneAndOnlyOne('w:tblPr') + tblGrid = OneAndOnlyOne('w:tblGrid') + tr = ZeroOrMore('w:tr') @classmethod def new(cls): @@ -56,35 +44,6 @@ def new(cls): tbl.append(tblGrid) return tbl - @property - def tblGrid(self): - tblGrid = self.find(qn('w:tblGrid')) - if tblGrid is None: - raise InvalidXmlError('required w:tblGrid child not found') - return tblGrid - - @property - def tblPr(self): - tblPr = self.find(qn('w:tblPr')) - if tblPr is None: - raise InvalidXmlError('required w:tblPr child not found') - return tblPr - - @property - def tr_lst(self): - """ - Sequence containing the ```` child elements in this - ````. - """ - return self.findall(qn('w:tr')) - - def _append_tr(self, tr): - """ - Return *tr* after appending it to end of tr sequence. - """ - self.append(tr) - return tr - class CT_TblGrid(BaseOxmlElement): """ From 6cd7cd8e028be340162c3cf656c46f421a007e56 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 23:11:43 -0700 Subject: [PATCH 066/809] oxml: convert CT_TblGrid to xmlchemy --- docx/oxml/table.py | 44 +------------------------------------------- 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index ca41a4109..79c8841cc 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -50,21 +50,7 @@ class CT_TblGrid(BaseOxmlElement): ```` element, child of ````, holds ```` elements that define column count, width, etc. """ - def add_gridCol(self): - """ - Return a new element that has been added at the end of - any existing gridCol elements. - """ - gridCol = CT_TblGridCol.new() - return self._append_gridCol(gridCol) - - @property - def gridCol_lst(self): - """ - Sequence containing the ```` child elements in this - ````. - """ - return self.findall(qn('w:gridCol')) + gridCol = ZeroOrMore('w:gridCol', successors=('w:tblGridChange',)) @classmethod def new(cls): @@ -73,40 +59,12 @@ def new(cls): """ return OxmlElement('w:tblGrid') - def _append_gridCol(self, gridCol): - """ - Return *gridCol* after appending it to end of gridCol sequence. - """ - successor = self.first_child_found_in('w:tblGridChange') - if successor is not None: - successor.addprevious(gridCol) - else: - self.append(gridCol) - return gridCol - - def first_child_found_in(self, *tagnames): - """ - Return the first child found with tag in *tagnames*, or None if - not found. - """ - for tagname in tagnames: - child = self.find(qn(tagname)) - if child is not None: - return child - return None - class CT_TblGridCol(BaseOxmlElement): """ ```` element, child of ````, defines a table column. """ - @classmethod - def new(cls): - """ - Return a new ```` element. - """ - return OxmlElement('w:gridCol') class CT_TblPr(BaseOxmlElement): From 965aaf125b9822d7f401ed535677762a54c00dfd Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 23:26:26 -0700 Subject: [PATCH 067/809] oxml: convert CT_TblPr to xmlchemy --- docs/dev/analysis/features/table.rst | 70 ++++++++++++++-------------- docx/oxml/table.py | 28 +++-------- 2 files changed, 41 insertions(+), 57 deletions(-) diff --git a/docs/dev/analysis/features/table.rst b/docs/dev/analysis/features/table.rst index fa964aa2c..b9f1a894a 100644 --- a/docs/dev/analysis/features/table.rst +++ b/docs/dev/analysis/features/table.rst @@ -114,22 +114,22 @@ Schema Definitions - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + @@ -170,8 +170,8 @@ Schema Definitions - - + + @@ -191,7 +191,7 @@ Schema Definitions - + @@ -222,22 +222,22 @@ Schema Definitions - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 79c8841cc..d6085367b 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -8,9 +8,8 @@ from . import OxmlElement from .ns import qn -from .shared import CT_String from .text import CT_P -from .xmlchemy import BaseOxmlElement, OneAndOnlyOne, ZeroOrMore +from .xmlchemy import BaseOxmlElement, OneAndOnlyOne, ZeroOrOne, ZeroOrMore class CT_Row(BaseOxmlElement): @@ -72,13 +71,14 @@ class CT_TblPr(BaseOxmlElement): ```` element, child of ````, holds child elements that define table properties such as style and borders. """ + tblStyle = ZeroOrOne('w:tblStyle') + def add_tblStyle(self, style_name): """ - Return a new element newly inserted in sequence among - the existing child elements, respecting the schema definition. + Return a new element having its style set to + *style_name*. """ - tblStyle = CT_String.new('w:tblStyle', style_name) - return self._insert_tblStyle(tblStyle) + return self._add_tblStyle(val=style_name) @classmethod def new(cls): @@ -87,22 +87,6 @@ def new(cls): """ return OxmlElement('w:tblPr') - @property - def tblStyle(self): - """ - Optional child element, or |None| if not present. - """ - return self.find(qn('w:tblStyle')) - - def _insert_tblStyle(self, tblStyle): - """ - Return *tblStyle* after inserting it in sequence among the existing - child elements. Assumes no ```` element is present. - """ - assert self.tblStyle is None - self.insert(0, tblStyle) - return tblStyle - class CT_Tc(BaseOxmlElement): """ From 7592a60ea91d4e5ea356289a78faab6e6d2c4471 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 16 Jun 2014 23:52:52 -0700 Subject: [PATCH 068/809] oxml: convert CT_Tc to xmlchemy --- docx/oxml/table.py | 43 ++++++++++++++++------------------------- docx/oxml/text.py | 9 --------- tests/oxml/test_text.py | 7 +------ 3 files changed, 18 insertions(+), 41 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index d6085367b..883653f4d 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -7,9 +7,9 @@ from __future__ import absolute_import, print_function, unicode_literals from . import OxmlElement -from .ns import qn -from .text import CT_P -from .xmlchemy import BaseOxmlElement, OneAndOnlyOne, ZeroOrOne, ZeroOrMore +from .xmlchemy import ( + BaseOxmlElement, OneAndOnlyOne, OneOrMore, ZeroOrOne, ZeroOrMore +) class CT_Row(BaseOxmlElement): @@ -92,19 +92,25 @@ class CT_Tc(BaseOxmlElement): """ ```` table cell element """ - def add_p(self): + tcPr = ZeroOrOne('w:tcPr') # bunches of successors, overriding insert + p = OneOrMore('w:p') + + def _insert_tcPr(self, tcPr): """ - Return a new element that has been added at the end of any - existing cell content. + ``tcPr`` has a bunch of successors, but it comes first if it appears, + so just overriding and using insert(0, ...) rather than spelling out + successors. """ - p = CT_P.new() - self.append(p) - return p + self.insert(0, tcPr) + return tcPr def clear_content(self): """ Remove all content child elements, preserving the ```` - element if present. + element if present. Note that this leaves the ```` element in + an invalid state because it doesn't contain at least one block-level + element. It's up to the caller to add a ```` or ```` + child element. """ new_children = [] tcPr = self.tcPr @@ -119,20 +125,5 @@ def new(cls): required EG_BlockLevelElt. """ tc = OxmlElement('w:tc') - p = CT_P.new() - tc.append(p) + tc._add_p() return tc - - @property - def p_lst(self): - """ - List of child elements. - """ - return self.findall(qn('w:p')) - - @property - def tcPr(self): - """ - child element or |None| if not present. - """ - return self.find(qn('w:tcPr')) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index fec115095..34ff71f9a 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -61,15 +61,6 @@ def get_or_add_pPr(self): pPr = self._add_pPr() return pPr - @staticmethod - def new(): - """ - Return a new ```` element. - """ - xml = '' % nsdecls('w') - p = parse_xml(xml) - return p - @property def pPr(self): """ diff --git a/tests/oxml/test_text.py b/tests/oxml/test_text.py index e0b38a71f..af8178986 100644 --- a/tests/oxml/test_text.py +++ b/tests/oxml/test_text.py @@ -4,18 +4,13 @@ Test suite for the docx.oxml.text module. """ -from docx.oxml.text import CT_P, CT_PPr, CT_R, CT_Text +from docx.oxml.text import CT_PPr, CT_R, CT_Text from .unitdata.text import a_p, a_pPr, a_pStyle, a_t, an_r class DescribeCT_P(object): - def it_can_construct_a_new_p_element(self): - p = CT_P.new() - expected_xml = a_p().with_nsdecls().xml() - assert p.xml == expected_xml - def it_has_a_sequence_of_the_runs_it_contains(self): p = a_p().with_nsdecls().with_child(an_r()).with_child(an_r()).element assert len(p.r_lst) == 2 From 9022cbac3ba05550a42d9b5c212884502a89f43f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 00:27:51 -0700 Subject: [PATCH 069/809] oxml: convert CT_Br to xmlchemy --- docx/oxml/simpletypes.py | 24 ++++++++++++++++++++++++ docx/oxml/text.py | 35 +++++------------------------------ 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 04fa5383e..45039bcf8 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -153,6 +153,30 @@ def validate(cls, value): cls.validate_int_in_range(value, 0, 4294967295) +class ST_BrClear(XsdString): + + @classmethod + def validate(cls, value): + cls.validate_string(value) + valid_values = ('none', 'left', 'right', 'all') + if value not in valid_values: + 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') + if value not in valid_values: + raise ValueError( + "must be one of %s, got '%s'" % (valid_values, value) + ) + + class ST_Coordinate(BaseIntType): @classmethod diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 34ff71f9a..8f212cd96 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -9,35 +9,16 @@ from ..enum.text import WD_UNDERLINE from .ns import nsdecls, qn from .shared import CT_String -from .xmlchemy import BaseOxmlElement +from .simpletypes import ST_BrClear, ST_BrType +from .xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrMore class CT_Br(BaseOxmlElement): """ ```` element, indicating a line, page, or column break in a run. """ - @classmethod - def new(cls): - """ - Return a new ```` element. - """ - return OxmlElement('w:br') - - @property - def clear(self): - self.get(qn('w:clear')) - - @clear.setter - def clear(self, clear_str): - self.set(qn('w:clear'), clear_str) - - @property - def type(self): - return self.get(qn('w:type')) - - @type.setter - def type(self, type_str): - self.set(qn('w:type'), type_str) + type = OptionalAttribute('w:type', ST_BrType) + clear = OptionalAttribute('w:clear', ST_BrClear) class CT_P(BaseOxmlElement): @@ -208,13 +189,7 @@ class CT_R(BaseOxmlElement): """ ```` element, containing the properties and text for a run. """ - def add_br(self): - """ - Return a newly appended CT_Br () child element. - """ - br = CT_Br.new() - self.append(br) - return br + br = ZeroOrMore('w:br') def add_drawing(self, inline_or_anchor): """ From a97f43d7b22db7f7857b484364a2ec8a0a3dc392 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 00:35:48 -0700 Subject: [PATCH 070/809] oxml: convert CT_P to xmlchemy --- docx/oxml/text.py | 44 +++++++------------------------------------- 1 file changed, 7 insertions(+), 37 deletions(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 8f212cd96..3ed0eda26 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -10,7 +10,9 @@ from .ns import nsdecls, qn from .shared import CT_String from .simpletypes import ST_BrClear, ST_BrType -from .xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrMore +from .xmlchemy import ( + BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne +) class CT_Br(BaseOxmlElement): @@ -25,37 +27,13 @@ class CT_P(BaseOxmlElement): """ ```` element, containing the properties and text for a paragraph. """ - def add_r(self): - """ - Return a newly added CT_R () element. - """ - r = CT_R.new() - self.append(r) - return r + pPr = ZeroOrOne('w:pPr') + r = ZeroOrMore('w:r') - def get_or_add_pPr(self): - """ - Return the pPr child element, newly added if not present. - """ - pPr = self.pPr - if pPr is None: - pPr = self._add_pPr() + def _insert_pPr(self, pPr): + self.insert(0, pPr) return pPr - @property - def pPr(self): - """ - ```` child element or None if not present. - """ - return self.find(qn('w:pPr')) - - @property - def r_lst(self): - """ - Sequence containing a reference to each run element in this paragraph. - """ - return self.findall(qn('w:r')) - @property def style(self): """ @@ -76,14 +54,6 @@ def style(self, style): pPr = self.get_or_add_pPr() pPr.style = style - def _add_pPr(self): - """ - Return a newly added pPr child element. Assumes one is not present. - """ - pPr = CT_PPr.new() - self.insert(0, pPr) - return pPr - class CT_PPr(BaseOxmlElement): """ From 6d1236056a6dd8a814833107381136f55bf2d9a5 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 01:06:51 -0700 Subject: [PATCH 071/809] oxml: convert CT_PPr to xmlchemy --- docs/dev/analysis/schema/ct_ppr.rst | 91 ++++++++++++--------------- docx/oxml/shared.py | 10 --- docx/oxml/text.py | 97 +++++++---------------------- tests/oxml/test_text.py | 7 +-- 4 files changed, 64 insertions(+), 141 deletions(-) diff --git a/docs/dev/analysis/schema/ct_ppr.rst b/docs/dev/analysis/schema/ct_ppr.rst index 160e25867..a872dcca9 100644 --- a/docs/dev/analysis/schema/ct_ppr.rst +++ b/docs/dev/analysis/schema/ct_ppr.rst @@ -74,19 +74,7 @@ Schema excerpt :: - - - - - - - - - - - - - + @@ -120,41 +108,44 @@ Schema excerpt - + + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + @@ -166,14 +157,14 @@ Schema excerpt - - - - - - - - + + + + + + + + @@ -182,7 +173,7 @@ Schema excerpt - + diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index 904933630..eeec5540a 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -54,16 +54,6 @@ def new(cls, nsptagname, val): elm.val = val return elm - @classmethod - def new_pStyle(cls, val): - """ - Return a new ```` element with ``val`` attribute set to - *val*. - """ - pStyle = OxmlElement('w:pStyle') - pStyle.val = val - return pStyle - @classmethod def new_rStyle(cls, val): """ diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 3ed0eda26..f4b29177c 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -5,9 +5,9 @@ (CT_R). """ -from . import parse_xml, OxmlElement +from . import OxmlElement from ..enum.text import WD_UNDERLINE -from .ns import nsdecls, qn +from .ns import qn from .shared import CT_String from .simpletypes import ST_BrClear, ST_BrType from .xmlchemy import ( @@ -59,52 +59,24 @@ class CT_PPr(BaseOxmlElement): """ ```` element, containing the properties for a paragraph. """ - def get_or_add_numPr(self): - """ - Return the numPr child element, newly added if not present. - """ - numPr = self.numPr - if numPr is None: - numPr = self._add_numPr() - return numPr + __child_sequence__ = ( + 'w:pStyle', 'w:keepNext', 'w:keepLines', 'w:pageBreakBefore', + 'w:framePr', 'w:widowControl', 'w:numPr', 'w:suppressLineNumbers', + 'w:pBdr', 'w:shd', 'w:tabs', 'w:suppressAutoHyphens', 'w:kinsoku', + 'w:wordWrap', 'w:overflowPunct', 'w:topLinePunct', 'w:autoSpaceDE', + 'w:autoSpaceDN', 'w:bidi', 'w:adjustRightInd', 'w:snapToGrid', + 'w:spacing', 'w:ind', 'w:contextualSpacing', 'w:mirrorIndents', + 'w:suppressOverlap', 'w:jc', 'w:textDirection', 'w:textAlignment', + 'w:textboxTightWrap', 'w:outlineLvl', 'w:divId', 'w:cnfStyle', + 'w:rPr', 'w:sectPr', 'w:pPrChange' + ) + pStyle = ZeroOrOne('w:pStyle') + numPr = ZeroOrOne('w:numPr', successors=__child_sequence__[7:]) - def get_or_add_pStyle(self): - """ - Return the pStyle child element, newly added if not present. - """ - pStyle = self.pStyle - if pStyle is None: - pStyle = self._add_pStyle() + def _insert_pStyle(self, pStyle): + self.insert(0, pStyle) return pStyle - @staticmethod - def new(): - """ - Return a new ```` element. - """ - xml = '' % nsdecls('w') - pPr = parse_xml(xml) - return pPr - - @property - def numPr(self): - """ - ```` child element or None if not present. - """ - return self.find(qn('w:numPr')) - - @property - def pStyle(self): - """ - ```` child element or None if not present. - """ - return self.find(qn('w:pStyle')) - - def remove_pStyle(self): - pStyle = self.pStyle - if pStyle is not None: - self.remove(pStyle) - @property def style(self): """ @@ -114,7 +86,7 @@ def style(self): pStyle = self.pStyle if pStyle is None: return None - return pStyle.get(qn('w:val')) + return pStyle.val @style.setter def style(self, style): @@ -124,35 +96,10 @@ def style(self, style): element if present. """ if style is None: - self.remove_pStyle() - elif self.pStyle is None: - self._add_pStyle(style) - else: - self.pStyle.val = style - - def _add_numPr(self): - numPr = OxmlElement('w:numPr') - return self._insert_numPr(numPr) - - def _add_pStyle(self, style): - pStyle = CT_String.new_pStyle(style) - return self._insert_pStyle(pStyle) - - def _insert_numPr(self, numPr): - return self.insert_element_before( - numPr, 'w:suppressLineNumbers', 'w:pBdr', 'w:shd', 'w:tabs', - 'w:suppressAutoHyphens', 'w:kinsoku', 'w:wordWrap', - 'w:overflowPunct', 'w:topLinePunct', 'w:autoSpaceDE', - 'w:autoSpaceDN', 'w:bidi', 'w:adjustRightInd', 'w:snapToGrid', - 'w:spacing', 'w:ind', 'w:contextualSpacing', 'w:mirrorIndents', - 'w:suppressOverlap', 'w:jc', 'w:textDirection', - 'w:textAlignment', 'w:textboxTightWrap', 'w:outlineLvl', - 'w:divId', 'w:cnfStyle', 'w:rPr', 'w:sectPr', 'w:pPrChange' - ) - - def _insert_pStyle(self, pStyle): - self.insert(0, pStyle) - return pStyle + self._remove_pStyle() + return + pStyle = self.get_or_add_pStyle() + pStyle.val = style class CT_R(BaseOxmlElement): diff --git a/tests/oxml/test_text.py b/tests/oxml/test_text.py index af8178986..dbba31c69 100644 --- a/tests/oxml/test_text.py +++ b/tests/oxml/test_text.py @@ -4,7 +4,7 @@ Test suite for the docx.oxml.text module. """ -from docx.oxml.text import CT_PPr, CT_R, CT_Text +from docx.oxml.text import CT_R, CT_Text from .unitdata.text import a_p, a_pPr, a_pStyle, a_t, an_r @@ -53,11 +53,6 @@ def it_can_set_its_paragraph_style(self): class DescribeCT_PPr(object): - def it_can_construct_a_new_pPr_element(self): - pPr = CT_PPr.new() - expected_xml = a_pPr().with_nsdecls().xml() - assert pPr.xml == expected_xml - def it_knows_the_paragraph_style(self): cases = ( (a_pPr(), None), From 34a475bb5c55065ff3a0da0f9ed676994091452a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 01:29:05 -0700 Subject: [PATCH 072/809] oxml: convert CT_R to xmlchemy --- docx/oxml/text.py | 69 +++++++++-------------------------------- tests/oxml/test_text.py | 12 ------- 2 files changed, 14 insertions(+), 67 deletions(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index f4b29177c..bb64bf977 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -106,50 +106,32 @@ 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') + drawing = ZeroOrMore('w:drawing') - def add_drawing(self, inline_or_anchor): - """ - Return a newly appended ``CT_Drawing`` (````) child - element having *inline_or_anchor* as its child. - """ - drawing = OxmlElement('w:drawing') - self.append(drawing) - drawing.append(inline_or_anchor) - return drawing + def _insert_rPr(self, rPr): + self.insert(0, rPr) + return rPr def add_t(self, text): """ - Return a newly added CT_T () element containing *text*. + Return a newly added ```` element containing *text*. """ - t = CT_Text.new(text) + t = self._add_t(text=text) if len(text.strip()) < len(text): t.set(qn('xml:space'), 'preserve') - self.append(t) return t - def get_or_add_rPr(self): - """ - Return the rPr child element, newly added if not present. - """ - rPr = self.rPr - if rPr is None: - rPr = self._add_rPr() - return rPr - - @classmethod - def new(cls): - """ - Return a new ```` element. - """ - return OxmlElement('w:r') - - @property - def rPr(self): + def add_drawing(self, inline_or_anchor): """ - ```` child element or None if not present. + Return a newly appended ``CT_Drawing`` (````) child + element having *inline_or_anchor* as its child. """ - return self.find(qn('w:rPr')) + drawing = self._add_drawing() + drawing.append(inline_or_anchor) + return drawing @property def style(self): @@ -171,13 +153,6 @@ def style(self, style): rPr = self.get_or_add_rPr() rPr.style = style - @property - def t_lst(self): - """ - Sequence of elements in this paragraph. - """ - return self.findall(qn('w:t')) - @property def underline(self): """ @@ -194,14 +169,6 @@ def underline(self, value): rPr = self.get_or_add_rPr() rPr.underline = value - def _add_rPr(self): - """ - Return a newly added rPr child element. Assumes one is not present. - """ - rPr = CT_RPr.new() - self.insert(0, rPr) - return rPr - class CT_RPr(BaseOxmlElement): """ @@ -699,14 +666,6 @@ class CT_Text(BaseOxmlElement): """ ```` element, containing a sequence of characters within a run. """ - @classmethod - def new(cls, text): - """ - Return a new ```` element. - """ - t = OxmlElement('w:t') - t.text = text - return t class CT_Underline(BaseOxmlElement): diff --git a/tests/oxml/test_text.py b/tests/oxml/test_text.py index dbba31c69..b53016d1d 100644 --- a/tests/oxml/test_text.py +++ b/tests/oxml/test_text.py @@ -81,10 +81,6 @@ def it_can_set_the_paragraph_style(self): class DescribeCT_R(object): - def it_can_construct_a_new_r_element(self): - r = CT_R.new() - assert r.xml == an_r().with_nsdecls().xml() - def it_can_add_a_t_to_itself(self): text = 'foobar' r = an_r().with_nsdecls().element @@ -111,11 +107,3 @@ def it_has_a_sequence_of_the_t_elms_it_contains(self): assert len(r.t_lst) == expected_len for t in r.t_lst: assert isinstance(t, CT_Text) - - -class DescribeCT_Text(object): - - def it_can_construct_a_new_t_element(self): - text = 'foobar' - t = CT_Text.new(text) - assert t.xml == a_t().with_nsdecls().with_text(text).xml() From 6f1a000de6d69d4177abbe027e1c6a681700f717 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 11:42:58 -0700 Subject: [PATCH 073/809] oxml: convert CT_RPr to xmlchemy --- docs/dev/analysis/features/bool-run-props.rst | 94 ++-- docx/oxml/shared.py | 10 - docx/oxml/text.py | 473 +----------------- docx/text.py | 4 +- 4 files changed, 69 insertions(+), 512 deletions(-) diff --git a/docs/dev/analysis/features/bool-run-props.rst b/docs/dev/analysis/features/bool-run-props.rst index b77bb0f34..d811f2e49 100644 --- a/docs/dev/analysis/features/bool-run-props.rst +++ b/docs/dev/analysis/features/bool-run-props.rst @@ -138,63 +138,53 @@ below when it writes the file.:: - + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docx/oxml/shared.py b/docx/oxml/shared.py index eeec5540a..1e21ba366 100644 --- a/docx/oxml/shared.py +++ b/docx/oxml/shared.py @@ -53,13 +53,3 @@ def new(cls, nsptagname, val): elm = OxmlElement(nsptagname) elm.val = val return elm - - @classmethod - def new_rStyle(cls, val): - """ - Return a new ```` element with ``val`` attribute set to - *val*. - """ - rStyle = OxmlElement('w:rStyle') - rStyle.val = val - return rStyle diff --git a/docx/oxml/text.py b/docx/oxml/text.py index bb64bf977..b31f03057 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -5,10 +5,8 @@ (CT_R). """ -from . import OxmlElement from ..enum.text import WD_UNDERLINE from .ns import qn -from .shared import CT_String from .simpletypes import ST_BrClear, ST_BrType from .xmlchemy import ( BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne @@ -174,415 +172,28 @@ class CT_RPr(BaseOxmlElement): """ ```` element, containing the properties for a run. """ - def add_b(self): - """ - Return a newly added child element. - """ - b = OxmlElement('w:b') - self.insert(0, b) - return b - - def add_bCs(self): - """ - Return a newly added child element. - """ - bCs = OxmlElement('w:bCs') - self.insert(0, bCs) - return bCs - - def add_caps(self): - """ - Return a newly added child element. - """ - caps = OxmlElement('w:caps') - self.insert(0, caps) - return caps - - def add_cs(self): - """ - Return a newly added child element. - """ - cs = OxmlElement('w:cs') - self.insert(0, cs) - return cs - - def add_dstrike(self): - """ - Return a newly added child element. - """ - dstrike = OxmlElement('w:dstrike') - self.insert(0, dstrike) - return dstrike - - def add_emboss(self): - """ - Return a newly added child element. - """ - emboss = OxmlElement('w:emboss') - self.insert(0, emboss) - return emboss - - def add_i(self): - """ - Return a newly added child element. - """ - i = OxmlElement('w:i') - self.insert(0, i) - return i - - def add_iCs(self): - """ - Return a newly added child element. - """ - iCs = OxmlElement('w:iCs') - self.insert(0, iCs) - return iCs - - def add_imprint(self): - """ - Return a newly added child element. - """ - imprint = OxmlElement('w:imprint') - self.insert(0, imprint) - return imprint - - def add_noProof(self): - """ - Return a newly added child element. - """ - noProof = OxmlElement('w:noProof') - self.insert(0, noProof) - return noProof - - def add_oMath(self): - """ - Return a newly added child element. - """ - oMath = OxmlElement('w:oMath') - self.insert(0, oMath) - return oMath - - def add_outline(self): - """ - Return a newly added child element. - """ - outline = OxmlElement('w:outline') - self.insert(0, outline) - return outline - - def add_rtl(self): - """ - Return a newly added child element. - """ - rtl = OxmlElement('w:rtl') - self.insert(0, rtl) - return rtl - - def add_shadow(self): - """ - Return a newly added child element. - """ - shadow = OxmlElement('w:shadow') - self.insert(0, shadow) - return shadow - - def add_smallCaps(self): - """ - Return a newly added child element. - """ - smallCaps = OxmlElement('w:smallCaps') - self.insert(0, smallCaps) - return smallCaps - - def add_snapToGrid(self): - """ - Return a newly added child element. - """ - snapToGrid = OxmlElement('w:snapToGrid') - self.insert(0, snapToGrid) - return snapToGrid - - def add_specVanish(self): - """ - Return a newly added child element. - """ - specVanish = OxmlElement('w:specVanish') - self.insert(0, specVanish) - return specVanish - - def add_strike(self): - """ - Return a newly added child element. - """ - strike = OxmlElement('w:strike') - self.insert(0, strike) - return strike - - def add_vanish(self): - """ - Return a newly added child element. - """ - vanish = OxmlElement('w:vanish') - self.insert(0, vanish) - return vanish - - def add_webHidden(self): - """ - Return a newly added child element. - """ - webHidden = OxmlElement('w:webHidden') - self.insert(0, webHidden) - return webHidden - - @property - def b(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:b')) - - @property - def bCs(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:bCs')) - - @property - def caps(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:caps')) - - @property - def cs(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:cs')) - - @property - def dstrike(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:dstrike')) - - @property - def emboss(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:emboss')) - - @property - def i(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:i')) - - @property - def iCs(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:iCs')) - - @property - def imprint(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:imprint')) - - @classmethod - def new(cls): - """ - Return a new ```` element. - """ - return OxmlElement('w:rPr') - - @property - def noProof(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:noProof')) - - @property - def oMath(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:oMath')) - - @property - def outline(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:outline')) - - def remove_b(self): - b_lst = self.findall(qn('w:b')) - for b in b_lst: - self.remove(b) - - def remove_bCs(self): - bCs_lst = self.findall(qn('w:bCs')) - for bCs in bCs_lst: - self.remove(bCs) - - def remove_caps(self): - caps_lst = self.findall(qn('w:caps')) - for caps in caps_lst: - self.remove(caps) - - def remove_cs(self): - cs_lst = self.findall(qn('w:cs')) - for cs in cs_lst: - self.remove(cs) - - def remove_dstrike(self): - dstrike_lst = self.findall(qn('w:dstrike')) - for dstrike in dstrike_lst: - self.remove(dstrike) - - def remove_emboss(self): - emboss_lst = self.findall(qn('w:emboss')) - for emboss in emboss_lst: - self.remove(emboss) - - def remove_i(self): - i_lst = self.findall(qn('w:i')) - for i in i_lst: - self.remove(i) - - def remove_iCs(self): - iCs_lst = self.findall(qn('w:iCs')) - for iCs in iCs_lst: - self.remove(iCs) - - def remove_imprint(self): - imprint_lst = self.findall(qn('w:imprint')) - for imprint in imprint_lst: - self.remove(imprint) - - def remove_noProof(self): - noProof_lst = self.findall(qn('w:noProof')) - for noProof in noProof_lst: - self.remove(noProof) - - def remove_oMath(self): - oMath_lst = self.findall(qn('w:oMath')) - for oMath in oMath_lst: - self.remove(oMath) - - def remove_outline(self): - outline_lst = self.findall(qn('w:outline')) - for outline in outline_lst: - self.remove(outline) - - def remove_rStyle(self): - rStyle = self.rStyle - if rStyle is not None: - self.remove(rStyle) - - def remove_rtl(self): - rtl_lst = self.findall(qn('w:rtl')) - for rtl in rtl_lst: - self.remove(rtl) - - def remove_shadow(self): - shadow_lst = self.findall(qn('w:shadow')) - for shadow in shadow_lst: - self.remove(shadow) - - def remove_smallCaps(self): - smallCaps_lst = self.findall(qn('w:smallCaps')) - for smallCaps in smallCaps_lst: - self.remove(smallCaps) - - def remove_snapToGrid(self): - snapToGrid_lst = self.findall(qn('w:snapToGrid')) - for snapToGrid in snapToGrid_lst: - self.remove(snapToGrid) - - def remove_specVanish(self): - specVanish_lst = self.findall(qn('w:specVanish')) - for specVanish in specVanish_lst: - self.remove(specVanish) - - def remove_strike(self): - strike_lst = self.findall(qn('w:strike')) - for strike in strike_lst: - self.remove(strike) - - def remove_u(self): - u_lst = self.findall(qn('w:u')) - for u in u_lst: - self.remove(u) - - def remove_vanish(self): - vanish_lst = self.findall(qn('w:vanish')) - for vanish in vanish_lst: - self.remove(vanish) - - def remove_webHidden(self): - webHidden_lst = self.findall(qn('w:webHidden')) - for webHidden in webHidden_lst: - self.remove(webHidden) - - @property - def rStyle(self): - """ - ```` child element or None if not present. - """ - return self.find(qn('w:rStyle')) - - @property - def rtl(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:rtl')) - - @property - def shadow(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:shadow')) - - @property - def smallCaps(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:smallCaps')) - - @property - def snapToGrid(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:snapToGrid')) - - @property - def specVanish(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:specVanish')) - - @property - def strike(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:strike')) + rStyle = ZeroOrOne('w:rStyle', successors=('w:rPrChange',)) + b = ZeroOrOne('w:b', successors=('w:rPrChange',)) + bCs = ZeroOrOne('w:bCs', successors=('w:rPrChange',)) + caps = ZeroOrOne('w:caps', successors=('w:rPrChange',)) + cs = ZeroOrOne('w:cs', successors=('w:rPrChange',)) + dstrike = ZeroOrOne('w:dstrike', successors=('w:rPrChange',)) + emboss = ZeroOrOne('w:emboss', successors=('w:rPrChange',)) + i = ZeroOrOne('w:i', successors=('w:rPrChange',)) + iCs = ZeroOrOne('w:iCs', successors=('w:rPrChange',)) + imprint = ZeroOrOne('w:imprint', successors=('w:rPrChange',)) + noProof = ZeroOrOne('w:noProof', successors=('w:rPrChange',)) + oMath = ZeroOrOne('w:oMath', successors=('w:rPrChange',)) + outline = ZeroOrOne('w:outline', successors=('w:rPrChange',)) + rtl = ZeroOrOne('w:rtl', successors=('w:rPrChange',)) + shadow = ZeroOrOne('w:shadow', successors=('w:rPrChange',)) + smallCaps = ZeroOrOne('w:smallCaps', successors=('w:rPrChange',)) + snapToGrid = ZeroOrOne('w:snapToGrid', successors=('w:rPrChange',)) + specVanish = ZeroOrOne('w:specVanish', successors=('w:rPrChange',)) + strike = ZeroOrOne('w:strike', successors=('w:rPrChange',)) + u = ZeroOrOne('w:u', successors=('w:rPrChange',)) + vanish = ZeroOrOne('w:vanish', successors=('w:rPrChange',)) + webHidden = ZeroOrOne('w:webHidden', successors=('w:rPrChange',)) @property def style(self): @@ -603,19 +214,12 @@ def style(self, style): element if present. """ if style is None: - self.remove_rStyle() + self._remove_rStyle() elif self.rStyle is None: - self._add_rStyle(style) + self._add_rStyle(val=style) else: self.rStyle.val = style - @property - def u(self): - """ - First ```` child element or |None| if none are present. - """ - return self.find(qn('w:u')) - @property def underline(self): """ @@ -629,38 +233,11 @@ def underline(self): @underline.setter def underline(self, value): - self.remove_u() + self._remove_u() if value is not None: u = self._add_u() u.val = value - @property - def vanish(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:vanish')) - - @property - def webHidden(self): - """ - First ```` child element or None if none are present. - """ - return self.find(qn('w:webHidden')) - - def _add_rStyle(self, style): - rStyle = CT_String.new_rStyle(style) - self.insert(0, rStyle) - return rStyle - - def _add_u(self): - """ - Return a newly added child element. - """ - u = OxmlElement('w:u') - self.insert(0, u) - return u - class CT_Text(BaseOxmlElement): """ diff --git a/docx/text.py b/docx/text.py index ef5c0ab5c..11d5b15a7 100644 --- a/docx/text.py +++ b/docx/text.py @@ -20,12 +20,12 @@ def _get_prop_value(parent, attr_name): return getattr(parent, attr_name) def _remove_prop(parent, attr_name): - remove_method_name = 'remove_%s' % attr_name + remove_method_name = '_remove_%s' % attr_name remove_method = getattr(parent, remove_method_name) remove_method() def _add_prop(parent, attr_name): - add_method_name = 'add_%s' % attr_name + add_method_name = '_add_%s' % attr_name add_method = getattr(parent, add_method_name) return add_method() From 886628473ab0c6a8161f7909e826e660ba1d0b7e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 13:34:43 -0700 Subject: [PATCH 074/809] oxml: use templating for CT_Picture.new() instead of having a propagating new() method on each of its child elements. That turns out to be more code and single-purpose when used for required children, also way spread out, so harder to understand. --- docx/oxml/__init__.py | 9 ++- docx/oxml/shape.py | 163 ++++++++++++++++++++++++--------------- docx/oxml/simpletypes.py | 8 +- 3 files changed, 113 insertions(+), 67 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index bb6dca7b2..087c63d00 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -68,16 +68,21 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): from docx.oxml.shape import ( CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, - CT_GraphicalObjectData, CT_Inline, CT_Picture, CT_Point2D, - CT_PositiveSize2D + CT_GraphicalObjectData, CT_Inline, CT_NonVisualDrawingProps, CT_Picture, + CT_PictureNonVisual, CT_Point2D, CT_PositiveSize2D, 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:extent', CT_PositiveSize2D) register_element_cls('wp:inline', CT_Inline) diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index 9d07bd4b8..560de3739 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -4,10 +4,11 @@ Custom element classes for shape-related elements like ```` """ -from . import OxmlElement -from .ns import nsmap, nspfxmap, qn +from . import OxmlElement, parse_xml +from .ns import nsdecls, nsmap, nspfxmap from .simpletypes import ( - ST_Coordinate, ST_PositiveCoordinate, ST_RelationshipId, XsdToken + ST_Coordinate, ST_DrawingElementId, ST_PositiveCoordinate, + ST_RelationshipId, XsdString, XsdToken ) from .xmlchemy import ( BaseOxmlElement, OneAndOnlyOne, OptionalAttribute, RequiredAttribute, @@ -23,12 +24,6 @@ class CT_Blip(BaseOxmlElement): embed = OptionalAttribute('r:embed', ST_RelationshipId) link = OptionalAttribute('r:link', ST_RelationshipId) - @classmethod - def new(cls, rId): - blip = OxmlElement('a:blip') - blip.set(qn('r:embed'), rId) - return blip - class CT_BlipFillProperties(BaseOxmlElement): """ @@ -38,13 +33,6 @@ class CT_BlipFillProperties(BaseOxmlElement): 'a:srcRect', 'a:tile', 'a:stretch' )) - @classmethod - def new(cls, rId): - blipFill = OxmlElement('pic:blipFill') - blipFill.append(CT_Blip.new(rId)) - blipFill.append(CT_StretchInfoProperties.new()) - return blipFill - class CT_GraphicalObject(BaseOxmlElement): """ @@ -104,6 +92,9 @@ class CT_NonVisualDrawingProps(BaseOxmlElement): Used for ```` element, and perhaps others. Specifies the id and name of a DrawingML drawing. """ + id = RequiredAttribute('id', ST_DrawingElementId) + name = RequiredAttribute('name', XsdString) + @classmethod def new(cls, nsptagname_str, shape_id, name): elm = OxmlElement(nsptagname_str) @@ -117,16 +108,15 @@ class CT_NonVisualPictureProperties(BaseOxmlElement): ```` element, specifies picture locking and resize behaviors. """ - @classmethod - def new(cls): - return OxmlElement('pic:cNvPicPr') class CT_Picture(BaseOxmlElement): """ ```` element, a DrawingML picture """ + nvPicPr = OneAndOnlyOne('pic:nvPicPr') blipFill = OneAndOnlyOne('pic:blipFill') + spPr = OneAndOnlyOne('pic:spPr') @classmethod def new(cls, pic_id, filename, rId, cx, cy): @@ -135,25 +125,44 @@ def new(cls, pic_id, filename, rId, cx, cy): contents required to define a viable picture element, based on the values passed as parameters. """ - pic = OxmlElement('pic:pic', nsdecls=nspfxmap('pic', 'r')) - pic.append(CT_PictureNonVisual.new(pic_id, filename)) - pic.append(CT_BlipFillProperties.new(rId)) - pic.append(CT_ShapeProperties.new(cx, cy)) + pic = parse_xml(cls._pic_xml()) + pic.nvPicPr.cNvPr.id = pic_id + pic.nvPicPr.cNvPr.name = filename + pic.blipFill.blip.embed = rId + pic.spPr.cx = cx + pic.spPr.cy = cy return pic + @classmethod + def _pic_xml(cls): + return ( + '\n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + '' % nsdecls('pic', 'a', 'r') + ) + class CT_PictureNonVisual(BaseOxmlElement): """ ```` element, non-visual picture properties """ - @classmethod - def new(cls, pic_id, image_filename): - nvPicPr = OxmlElement('pic:nvPicPr') - nvPicPr.append(CT_NonVisualDrawingProps.new( - 'pic:cNvPr', pic_id, image_filename - )) - nvPicPr.append(CT_NonVisualPictureProperties.new()) - return nvPicPr + cNvPr = OneAndOnlyOne('pic:cNvPr') class CT_Point2D(BaseOxmlElement): @@ -164,13 +173,6 @@ class CT_Point2D(BaseOxmlElement): x = RequiredAttribute('x', ST_Coordinate) y = RequiredAttribute('y', ST_Coordinate) - @classmethod - def new(cls, nsptagname_str, x, y): - elm = OxmlElement(nsptagname_str) - elm.x = x - elm.y = y - return elm - class CT_PositiveSize2D(BaseOxmlElement): """ @@ -193,11 +195,6 @@ class CT_PresetGeometry2D(BaseOxmlElement): ```` element, specifies an preset autoshape geometry, such as ``rect``. """ - @classmethod - def new(cls, prst): - prstGeom = OxmlElement('a:prstGeom') - prstGeom.set('prst', prst) - return prstGeom class CT_RelativeRect(BaseOxmlElement): @@ -205,21 +202,46 @@ class CT_RelativeRect(BaseOxmlElement): ```` element, specifying picture should fill containing rectangle shape. """ - @classmethod - def new(cls): - return OxmlElement('a:fillRect') class CT_ShapeProperties(BaseOxmlElement): """ ```` element, specifies size and shape of picture container. """ - @classmethod - def new(cls, cx, cy): - spPr = OxmlElement('pic:spPr') - spPr.append(CT_Transform2D.new(cx, cy)) - spPr.append(CT_PresetGeometry2D.new('rect')) - return spPr + xfrm = ZeroOrOne('a:xfrm', successors=( + 'a:custGeom', 'a:prstGeom', 'a:ln', 'a:effectLst', 'a:effectDag', + 'a:scene3d', 'a:sp3d', 'a:extLst' + )) + + @property + def cx(self): + """ + Shape width as an instance of Emu, or None if not present. + """ + xfrm = self.xfrm + if xfrm is None: + return None + return xfrm.cx + + @cx.setter + def cx(self, value): + xfrm = self.get_or_add_xfrm() + xfrm.cx = value + + @property + def cy(self): + """ + Shape height as an instance of Emu, or None if not present. + """ + xfrm = self.xfrm + if xfrm is None: + return None + return xfrm.cy + + @cy.setter + def cy(self, value): + xfrm = self.get_or_add_xfrm() + xfrm.cy = value class CT_StretchInfoProperties(BaseOxmlElement): @@ -227,20 +249,35 @@ class CT_StretchInfoProperties(BaseOxmlElement): ```` element, specifies how picture should fill its containing shape. """ - @classmethod - def new(cls): - stretch = OxmlElement('a:stretch') - stretch.append(CT_RelativeRect.new()) - return stretch class CT_Transform2D(BaseOxmlElement): """ ```` element, specifies size and shape of picture container. """ - @classmethod - def new(cls, cx, cy): - spPr = OxmlElement('a:xfrm') - spPr.append(CT_Point2D.new('a:off', 0, 0)) - spPr.append(CT_PositiveSize2D.new('a:ext', cx, cy)) - return spPr + off = ZeroOrOne('a:off', successors=('a:ext',)) + ext = ZeroOrOne('a:ext', successors=()) + + @property + def cx(self): + ext = self.ext + if ext is None: + return None + return ext.cx + + @cx.setter + def cx(self, value): + ext = self.get_or_add_ext() + ext.cx = value + + @property + def cy(self): + ext = self.ext + if ext is None: + return None + return ext.cy + + @cy.setter + def cy(self, value): + ext = self.get_or_add_ext() + ext.cy = value diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 45039bcf8..cc0e94fe0 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -183,7 +183,7 @@ class ST_Coordinate(BaseIntType): def convert_from_xml(cls, 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 int(str_value) + return Emu(int(str_value)) @classmethod def validate(cls, value): @@ -201,6 +201,10 @@ class ST_DecimalNumber(XsdInt): pass +class ST_DrawingElementId(XsdUnsignedInt): + pass + + class ST_OnOff(XsdBoolean): @classmethod @@ -237,5 +241,5 @@ def convert_from_xml(cls, str_value): 'mm': 36000, 'cm': 360000, 'in': 914400, 'pt': 12700, 'pc': 152400, 'pi': 152400 }[units_part] - emu_value = int(round(quantity * multiplier)) + emu_value = Emu(int(round(quantity * multiplier))) return emu_value From 752af0d122100e590c1257a40e412aa371dae294 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 15:04:13 -0700 Subject: [PATCH 075/809] oxml: use templating for CT_Inline.new() --- docx/oxml/__init__.py | 1 + docx/oxml/shape.py | 65 ++++++++++++++++---------------------- tests/oxml/unitdata/dml.py | 20 ++++++++++++ tests/test_shape.py | 15 +++++---- 4 files changed, 57 insertions(+), 44 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 087c63d00..9142734a5 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -83,6 +83,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): 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) diff --git a/docx/oxml/shape.py b/docx/oxml/shape.py index 560de3739..ae58dd59d 100644 --- a/docx/oxml/shape.py +++ b/docx/oxml/shape.py @@ -4,8 +4,8 @@ Custom element classes for shape-related elements like ```` """ -from . import OxmlElement, parse_xml -from .ns import nsdecls, nsmap, nspfxmap +from . import parse_xml +from .ns import nsdecls from .simpletypes import ( ST_Coordinate, ST_DrawingElementId, ST_PositiveCoordinate, ST_RelationshipId, XsdString, XsdToken @@ -40,12 +40,6 @@ class CT_GraphicalObject(BaseOxmlElement): """ graphicData = OneAndOnlyOne('a:graphicData') - @classmethod - def new(cls, uri, pic): - graphic = OxmlElement('a:graphic') - graphic.append(CT_GraphicalObjectData.new(uri, pic)) - return graphic - class CT_GraphicalObjectData(BaseOxmlElement): """ @@ -54,19 +48,13 @@ class CT_GraphicalObjectData(BaseOxmlElement): pic = ZeroOrOne('pic:pic') uri = RequiredAttribute('uri', XsdToken) - @classmethod - def new(cls, uri, pic): - graphicData = OxmlElement('a:graphicData') - graphicData.uri = uri - graphicData._insert_pic(pic) - return graphicData - class CT_Inline(BaseOxmlElement): """ ```` element, container for an inline shape. """ extent = OneAndOnlyOne('wp:extent') + docPr = OneAndOnlyOne('wp:docPr') graphic = OneAndOnlyOne('a:graphic') @classmethod @@ -75,17 +63,32 @@ def new(cls, cx, cy, shape_id, pic): Return a new ```` element populated with the values passed as parameters. """ - name = 'Picture %d' % shape_id - uri = nsmap['pic'] - - inline = OxmlElement('wp:inline', nsdecls=nspfxmap('wp', 'r')) - inline.append(CT_PositiveSize2D.new('wp:extent', cx, cy)) - inline.append(CT_NonVisualDrawingProps.new( - 'wp:docPr', shape_id, name - )) - inline.append(CT_GraphicalObject.new(uri, pic)) + inline = parse_xml(cls._inline_xml()) + inline.extent.cx = cx + inline.extent.cy = cy + inline.docPr.id = shape_id + inline.docPr.name = 'Picture %d' % shape_id + inline.graphic.graphicData.uri = ( + 'http://schemas.openxmlformats.org/drawingml/2006/picture' + ) + inline.graphic.graphicData._insert_pic(pic) return inline + @classmethod + def _inline_xml(cls): + return ( + '\n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + '' % nsdecls('wp', 'a', 'pic', 'r') + ) + class CT_NonVisualDrawingProps(BaseOxmlElement): """ @@ -95,13 +98,6 @@ class CT_NonVisualDrawingProps(BaseOxmlElement): id = RequiredAttribute('id', ST_DrawingElementId) name = RequiredAttribute('name', XsdString) - @classmethod - def new(cls, nsptagname_str, shape_id, name): - elm = OxmlElement(nsptagname_str) - elm.set('id', str(shape_id)) - elm.set('name', name) - return elm - class CT_NonVisualPictureProperties(BaseOxmlElement): """ @@ -182,13 +178,6 @@ class CT_PositiveSize2D(BaseOxmlElement): cx = RequiredAttribute('cx', ST_PositiveCoordinate) cy = RequiredAttribute('cy', ST_PositiveCoordinate) - @classmethod - def new(cls, nsptagname_str, cx, cy): - elm = OxmlElement(nsptagname_str) - elm.cx = cx - elm.cy = cy - return elm - class CT_PresetGeometry2D(BaseOxmlElement): """ diff --git a/tests/oxml/unitdata/dml.py b/tests/oxml/unitdata/dml.py index ce2c51e2d..84518f8b7 100644 --- a/tests/oxml/unitdata/dml.py +++ b/tests/oxml/unitdata/dml.py @@ -37,6 +37,12 @@ class CT_GraphicalObjectDataBuilder(BaseBuilder): __attrs__ = ('uri',) +class CT_GraphicalObjectFrameLockingBuilder(BaseBuilder): + __tag__ = 'a:graphicFrameLocks' + __nspfxs__ = ('a',) + __attrs__ = ('noChangeAspect',) + + class CT_InlineBuilder(BaseBuilder): __tag__ = 'wp:inline' __nspfxs__ = ('wp',) @@ -52,6 +58,12 @@ def __init__(self, tag): super(CT_NonVisualDrawingPropsBuilder, self).__init__() +class CT_NonVisualGraphicFramePropertiesBuilder(BaseBuilder): + __tag__ = 'wp:cNvGraphicFramePr' + __nspfxs__ = ('wp',) + __attrs__ = () + + class CT_NonVisualPicturePropertiesBuilder(BaseBuilder): __tag__ = 'pic:cNvPicPr' __nspfxs__ = ('pic',) @@ -123,6 +135,10 @@ def a_blipFill(): return CT_BlipFillPropertiesBuilder() +def a_cNvGraphicFramePr(): + return CT_NonVisualGraphicFramePropertiesBuilder() + + def a_cNvPicPr(): return CT_NonVisualPicturePropertiesBuilder() @@ -151,6 +167,10 @@ def a_graphicData(): return CT_GraphicalObjectDataBuilder() +def a_graphicFrameLocks(): + return CT_GraphicalObjectFrameLockingBuilder() + + def a_pic(): return CT_PictureBuilder() diff --git a/tests/test_shape.py b/tests/test_shape.py index 254c23b5d..4c661c23b 100644 --- a/tests/test_shape.py +++ b/tests/test_shape.py @@ -15,9 +15,10 @@ from docx.shared import Length from .oxml.unitdata.dml import ( - a_blip, a_blipFill, a_cNvPr, a_cNvPicPr, a_docPr, a_fillRect, a_graphic, - a_graphicData, a_pic, a_prstGeom, a_stretch, an_ext, an_extent, - an_inline, an_nvPicPr, an_off, an_spPr, an_xfrm + a_blip, a_blipFill, a_cNvGraphicFramePr, a_cNvPr, a_cNvPicPr, a_docPr, + a_fillRect, a_graphic, a_graphicData, a_graphicFrameLocks, a_pic, + a_prstGeom, a_stretch, an_ext, an_extent, an_inline, an_nvPicPr, an_off, + an_spPr, an_xfrm ) from .oxml.unitdata.text import an_r from .unitutil import instance_mock @@ -98,10 +99,12 @@ def new_picture_fixture(self, request, image_part_, image_params): name = 'Picture %d' % shape_id uri = nsmap['pic'] expected_inline = ( - an_inline().with_nsdecls('r', 'wp', 'w').with_child( + an_inline().with_nsdecls('wp', 'a', 'pic', 'r', 'w').with_child( an_extent().with_cx(cx).with_cy(cy)).with_child( a_docPr().with_id(shape_id).with_name(name)).with_child( - a_graphic().with_nsdecls().with_child( + a_cNvGraphicFramePr().with_child( + a_graphicFrameLocks().with_noChangeAspect(1))).with_child( + a_graphic().with_child( a_graphicData().with_uri(uri).with_child( self._pic_bldr(filename, rId, cx, cy)))) ).element @@ -176,7 +179,7 @@ def _inline_with_uri(self, uri): def _pic_bldr(self, name, rId, cx, cy): return ( - a_pic().with_nsdecls().with_child( + a_pic().with_child( an_nvPicPr().with_child( a_cNvPr().with_id(0).with_name(name)).with_child( a_cNvPicPr())).with_child( From 8073bf790dc0434426d3eb5ce71e562e79513314 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 19:27:31 -0700 Subject: [PATCH 076/809] oxml: use templating for CT_Tbl.new() --- docs/dev/analysis/features/table.rst | 88 +++++++++++++--------------- docx/oxml/table.py | 42 ++++++------- tests/oxml/unitdata/table.py | 10 ++++ tests/parts/test_document.py | 7 ++- 4 files changed, 76 insertions(+), 71 deletions(-) diff --git a/docs/dev/analysis/features/table.rst b/docs/dev/analysis/features/table.rst index b9f1a894a..5defbe9c2 100644 --- a/docs/dev/analysis/features/table.rst +++ b/docs/dev/analysis/features/table.rst @@ -2,6 +2,7 @@ Table ===== +... column width is stored in twips (20ths of a point) ... MS API ------ @@ -104,10 +105,10 @@ Schema Definitions - - - - + + + + @@ -138,6 +139,39 @@ Schema Definitions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -159,6 +193,10 @@ Schema Definitions + + + + @@ -241,48 +279,6 @@ Schema Definitions -:: - - w_CT_Tc = - attribute w:id { s_ST_String }?, - element tcPr { w_CT_TcPr }?, - w_EG_BlockLevelElts+ - - w_EG_BlockLevelElts = # denormalized - element customXml { w_CT_CustomXmlBlock } - | element p { w_CT_P } - | element sdt { w_CT_SdtBlock } - | element tbl { w_CT_Tbl } - | element altChunk { w_CT_AltChunk } - - | element proofErr { w_CT_ProofErr } - | element permStart { w_CT_PermStart } - | element permEnd { w_CT_Perm } - | element ins { w_CT_RunTrackChange } - | element del { w_CT_RunTrackChange } - | element moveFrom { w_CT_RunTrackChange } - | element moveTo { w_CT_RunTrackChange } - - | element bookmarkStart { w_CT_Bookmark } - | element bookmarkEnd { w_CT_MarkupRange } - | element moveFromRangeStart { w_CT_MoveBookmark } - | element moveFromRangeEnd { w_CT_MarkupRange } - | element moveToRangeStart { w_CT_MoveBookmark } - | element moveToRangeEnd { w_CT_MarkupRange } - | element commentRangeStart { w_CT_MarkupRange } - | element commentRangeEnd { w_CT_MarkupRange } - | element customXmlInsRangeStart { w_CT_TrackChange } - | element customXmlInsRangeEnd { w_CT_Markup } - | element customXmlDelRangeStart { w_CT_TrackChange } - | element customXmlDelRangeEnd { w_CT_Markup } - | element customXmlMoveFromRangeStart { w_CT_TrackChange } - | element customXmlMoveFromRangeEnd { w_CT_Markup } - | element customXmlMoveToRangeStart { w_CT_TrackChange } - | element customXmlMoveToRangeEnd { w_CT_Markup } - - | element oMathPara { m_CT_OMathPara } - | element oMath { m_CT_OMath } - Resources --------- diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 883653f4d..6f22050de 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -6,7 +6,8 @@ from __future__ import absolute_import, print_function, unicode_literals -from . import OxmlElement +from . import parse_xml +from .ns import nsdecls from .xmlchemy import ( BaseOxmlElement, OneAndOnlyOne, OneOrMore, ZeroOrOne, ZeroOrMore ) @@ -36,13 +37,20 @@ def new(cls): Return a new ```` element, containing the required ```` and ```` child elements. """ - tbl = OxmlElement('w:tbl') - tblPr = CT_TblPr.new() - tbl.append(tblPr) - tblGrid = CT_TblGrid.new() - tbl.append(tblGrid) + tbl = parse_xml(cls._tbl_xml()) return tbl + @classmethod + def _tbl_xml(cls): + return ( + '\n' + ' \n' + ' \n' + ' \n' + ' \n' + '' % nsdecls('w') + ) + class CT_TblGrid(BaseOxmlElement): """ @@ -51,13 +59,6 @@ class CT_TblGrid(BaseOxmlElement): """ gridCol = ZeroOrMore('w:gridCol', successors=('w:tblGridChange',)) - @classmethod - def new(cls): - """ - Return a new ```` element. - """ - return OxmlElement('w:tblGrid') - class CT_TblGridCol(BaseOxmlElement): """ @@ -80,13 +81,6 @@ def add_tblStyle(self, style_name): """ return self._add_tblStyle(val=style_name) - @classmethod - def new(cls): - """ - Return a new ```` element. - """ - return OxmlElement('w:tblPr') - class CT_Tc(BaseOxmlElement): """ @@ -124,6 +118,8 @@ def new(cls): Return a new ```` element, containing an empty paragraph as the required EG_BlockLevelElt. """ - tc = OxmlElement('w:tc') - tc._add_p() - return tc + return parse_xml( + '\n' + ' \n' + '' % nsdecls('w') + ) diff --git a/tests/oxml/unitdata/table.py b/tests/oxml/unitdata/table.py index 852772550..5f0cb2722 100644 --- a/tests/oxml/unitdata/table.py +++ b/tests/oxml/unitdata/table.py @@ -38,6 +38,12 @@ class CT_TblPrBuilder(BaseBuilder): __attrs__ = () +class CT_TblWidthBuilder(BaseBuilder): + __tag__ = 'w:tblW' + __nspfxs__ = ('w',) + __attrs__ = ('w:w', 'w:type') + + class CT_TcBuilder(BaseBuilder): __tag__ = 'w:tc' __nspfxs__ = ('w',) @@ -70,6 +76,10 @@ def a_tblStyle(): return CT_StringBuilder('w:tblStyle') +def a_tblW(): + return CT_TblWidthBuilder() + + def a_tc(): return CT_TcBuilder() diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index ee94ff8e6..e4dcd1549 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -25,7 +25,7 @@ from ..oxml.unitdata.dml import a_drawing, an_inline from ..oxml.parts.unitdata.document import a_body, a_document from ..oxml.unitdata.table import ( - a_gridCol, a_tbl, a_tblGrid, a_tblPr, a_tc, a_tr + a_gridCol, a_tbl, a_tblGrid, a_tblPr, a_tblW, a_tc, a_tr ) from ..oxml.unitdata.text import a_p, a_sectPr, an_r from ..unitutil import ( @@ -437,7 +437,10 @@ def _body_bldr(self, p_count=0, tbl_bldr=None, sectPr=False): return body_bldr def _tbl_bldr(self, rows=1, cols=1): - tblPr_bldr = a_tblPr() + tblPr_bldr = ( + a_tblPr().with_child( + a_tblW().with_type("auto").with_w(0)) + ) tblGrid_bldr = a_tblGrid() for i in range(cols): From 617eadd9e2d0ba055be192caf00927b6ad380020 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 19:28:37 -0700 Subject: [PATCH 077/809] acpt: add tbl-col-props.feature With initial scenario outline for column width --- features/steps/table.py | 20 +++++++++++++++++++ features/steps/test_files/tbl-col-props.docx | Bin 0 -> 13654 bytes features/tbl-col-props.feature | 14 +++++++++++++ 3 files changed, 34 insertions(+) create mode 100644 features/steps/test_files/tbl-col-props.docx create mode 100644 features/tbl-col-props.feature diff --git a/features/steps/table.py b/features/steps/table.py index c3d334675..350ac6c52 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -56,6 +56,17 @@ def given_a_table(context): context.table_ = Document().add_table(rows=2, cols=2) +@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') + document = Document(docx_path) + context.column = document.tables[0].columns[col_idx] + + @given('a table having an applied style') def given_a_table_having_an_applied_style(context): docx_path = test_docx('tbl-having-applied-style') @@ -267,6 +278,15 @@ def then_new_row_has_2_cells(context): assert len(context.row.cells) == 2 +@then('the reported column width is {width_emu}') +def then_the_reported_column_width_is_width_emu(context, width_emu): + expected_value = { + 'None': None, + '914400': 914400, + }[width_emu] + assert context.column.width == expected_value + + @then('the table style matches the name I applied') def then_table_style_matches_name_applied(context): table = context.table_ diff --git a/features/steps/test_files/tbl-col-props.docx b/features/steps/test_files/tbl-col-props.docx new file mode 100644 index 0000000000000000000000000000000000000000..bfeace1d44456b018cdfee080ab9faa25baa3c83 GIT binary patch literal 13654 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 YA}DRaa1@4%ny8r+H literal 0 HcmV?d00001 diff --git a/features/tbl-col-props.feature b/features/tbl-col-props.feature new file mode 100644 index 000000000..ef19df450 --- /dev/null +++ b/features/tbl-col-props.feature @@ -0,0 +1,14 @@ +Feature: Get and set table column widths + In order to produce properly formatted tables + As an python-docx developer + I need a way to get and set the width of a table's columns + + @wip + Scenario Outline: Get existing column width + Given a table column having a width of + Then the reported column width is + + Examples: table column width values + | width | width-emu | + | no explicit setting | None | + | 1440 | 914400 | From e685394105b4bcc63d5f12139986179146e9cb7d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 20:19:27 -0700 Subject: [PATCH 078/809] tbl: add _Column.width getter Also, sequence Length methods in alpha order --- docs/dev/analysis/features/table.rst | 10 ++++++++ docx/oxml/__init__.py | 5 +++- docx/oxml/simpletypes.py | 24 ++++++++++++++++++- docx/oxml/table.py | 5 +++- docx/shared.py | 36 +++++++++++++++++++++------- docx/table.py | 8 +++++++ features/tbl-col-props.feature | 2 +- tests/test_table.py | 23 ++++++++++++++++++ 8 files changed, 100 insertions(+), 13 deletions(-) diff --git a/docs/dev/analysis/features/table.rst b/docs/dev/analysis/features/table.rst index 5defbe9c2..7812f5382 100644 --- a/docs/dev/analysis/features/table.rst +++ b/docs/dev/analysis/features/table.rst @@ -197,6 +197,16 @@ Schema Definitions + + + + + + + + + + diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 9142734a5..ea6275660 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -107,7 +107,10 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:style', CT_Style) register_element_cls('w:styles', CT_Styles) -from docx.oxml.table import CT_Row, CT_Tbl, CT_TblGrid, CT_TblPr, CT_Tc +from docx.oxml.table import ( + CT_Row, CT_Tbl, CT_TblGrid, CT_TblGridCol, CT_TblPr, CT_Tc +) +register_element_cls('w:gridCol', CT_TblGridCol) register_element_cls('w:tbl', CT_Tbl) register_element_cls('w:tblGrid', CT_TblGrid) register_element_cls('w:tblPr', CT_TblPr) diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index cc0e94fe0..5bb94b05b 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -8,7 +8,7 @@ from __future__ import absolute_import, print_function -from ..shared import Emu +from ..shared import Emu, Twips class BaseSimpleType(object): @@ -153,6 +153,13 @@ 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): @classmethod @@ -231,6 +238,21 @@ class ST_String(XsdString): pass +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: + return ST_UniversalMeasure.convert_from_xml(str_value) + return Twips(int(str_value)) + + @classmethod + def convert_to_xml(cls, value): + emu = Emu(value) + twips = emu.twips + return str(twips) + + class ST_UniversalMeasure(BaseSimpleType): @classmethod diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 6f22050de..486f47b8b 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -8,8 +8,10 @@ from . import parse_xml from .ns import nsdecls +from .simpletypes import ST_TwipsMeasure from .xmlchemy import ( - BaseOxmlElement, OneAndOnlyOne, OneOrMore, ZeroOrOne, ZeroOrMore + BaseOxmlElement, OneAndOnlyOne, OneOrMore, OptionalAttribute, ZeroOrOne, + ZeroOrMore ) @@ -65,6 +67,7 @@ class CT_TblGridCol(BaseOxmlElement): ```` element, child of ````, defines a table column. """ + w = OptionalAttribute('w:w', ST_TwipsMeasure) class CT_TblPr(BaseOxmlElement): diff --git a/docx/shared.py b/docx/shared.py index 509899e07..9f791d328 100644 --- a/docx/shared.py +++ b/docx/shared.py @@ -18,23 +18,31 @@ class Length(int): _EMUS_PER_CM = 360000 _EMUS_PER_MM = 36000 _EMUS_PER_PX = 12700 + _EMUS_PER_TWIP = 635 def __new__(cls, emu): return int.__new__(cls, emu) @property - def inches(self): + def cm(self): """ - The equivalent length expressed in inches (float). + The equivalent length expressed in centimeters (float). """ - return self / float(self._EMUS_PER_INCH) + return self / float(self._EMUS_PER_CM) @property - def cm(self): + def emu(self): """ - The equivalent length expressed in centimeters (float). + The equivalent length expressed in English Metric Units (int). """ - return self / float(self._EMUS_PER_CM) + return self + + @property + def inches(self): + """ + The equivalent length expressed in inches (float). + """ + return self / float(self._EMUS_PER_INCH) @property def mm(self): @@ -50,11 +58,11 @@ def px(self): return int(round(self / float(self._EMUS_PER_PX)) + 0.1) @property - def emu(self): + def twips(self): """ - The equivalent length expressed in English Metric Units (int). + The equivalent length expressed in twips (int). """ - return self + return int(round(self / float(self._EMUS_PER_TWIP)) + 0.1) class Inches(Length): @@ -116,6 +124,16 @@ def __new__(cls, px): return Length.__new__(cls, emu) +class Twips(Length): + """ + Convenience constructor for length in twips, e.g. ``width = Twips(42)``. + A twip is a twentieth of a point, 635 EMU. + """ + def __new__(cls, twips): + emu = int(twips * Length._EMUS_PER_TWIP) + return Length.__new__(cls, emu) + + def lazyproperty(f): """ @lazyprop decorator. Decorated method will be called only on first access diff --git a/docx/table.py b/docx/table.py index 65cf1250a..b615c1ee0 100644 --- a/docx/table.py +++ b/docx/table.py @@ -132,6 +132,14 @@ def cells(self): """ return _ColumnCells(self._tbl, self._gridCol) + @property + def width(self): + """ + The width of this column in EMU, or |None| if no explicit width is + set. + """ + return self._gridCol.w + class _ColumnCells(object): """ diff --git a/features/tbl-col-props.feature b/features/tbl-col-props.feature index ef19df450..1a8e47ddb 100644 --- a/features/tbl-col-props.feature +++ b/features/tbl-col-props.feature @@ -3,7 +3,7 @@ Feature: Get and set table column widths As an python-docx developer I need a way to get and set the width of a table's columns - @wip + Scenario Outline: Get existing column width Given a table column having a width of Then the reported column width is diff --git a/tests/test_table.py b/tests/test_table.py index 6cbb97001..e11c0ce6a 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -168,8 +168,31 @@ def it_provides_access_to_the_column_cells(self, column): cells = column.cells assert isinstance(cells, _ColumnCells) + def it_knows_its_width_in_EMU(self, width_fixture): + column, expected_width = width_fixture + assert column.width == expected_width + # fixtures ------------------------------------------------------- + @pytest.fixture(params=[ + (4242, 2693670), + (1440, 914400), + ('2.54cm', 914400), + ('54mm', 1944000), + ('12.5pt', 158750), + (None, None), + ]) + def width_fixture(self, request): + w, expected_width = request.param + gridCol_bldr = a_gridCol().with_nsdecls() + if w is not None: + gridCol_bldr.with_w(w) + gridCol = gridCol_bldr.element + column = _Column(gridCol, None) + return column, expected_width + + # fixture components --------------------------------------------- + @pytest.fixture def column(self): return _Column(None, None) From da43dac073b0e7f6881f4bbdd3e11c6762c1b2fe Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 20:28:54 -0700 Subject: [PATCH 079/809] acpt: add scenario for _Column.width setter --- features/steps/table.py | 11 +++++++---- features/tbl-col-props.feature | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/features/steps/table.py b/features/steps/table.py index 350ac6c52..e92b11619 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -124,6 +124,12 @@ def when_apply_style_to_table(context): table.style = 'LightShading-Accent1' +@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) + context.column.width = new_value + + # then ===================================================== @then('I can access a cell using its row and column indices') @@ -280,10 +286,7 @@ def then_new_row_has_2_cells(context): @then('the reported column width is {width_emu}') def then_the_reported_column_width_is_width_emu(context, width_emu): - expected_value = { - 'None': None, - '914400': 914400, - }[width_emu] + expected_value = None if width_emu == 'None' else int(width_emu) assert context.column.width == expected_value diff --git a/features/tbl-col-props.feature b/features/tbl-col-props.feature index 1a8e47ddb..661746c31 100644 --- a/features/tbl-col-props.feature +++ b/features/tbl-col-props.feature @@ -12,3 +12,18 @@ Feature: Get and set table column widths | width | width-emu | | no explicit setting | None | | 1440 | 914400 | + + + @wip + Scenario Outline: Set column width + Given a table column having a width of + When I set the column width to + Then the reported column width is + + Examples: table column width values + | width | new-width | + | no explicit setting | None | + | no explicit setting | 914400 | + | 1440 | None | + | 1440 | 914400 | + | 1440 | 424242 | From 756d1795807678d5a0da15446b8427ad80fcbc4e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 21:37:14 -0700 Subject: [PATCH 080/809] xmlch: fix discovered bug in OptionalAttribute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Didn’t delete optional attribute on assign None when attribute has namespace prefix. Lookup of attribute needs to be on clark_name, not attr_name. --- docx/oxml/xmlchemy.py | 4 ++-- tests/oxml/test_xmlchemy.py | 15 +++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docx/oxml/xmlchemy.py b/docx/oxml/xmlchemy.py index 02f452062..fdba5b8b6 100644 --- a/docx/oxml/xmlchemy.py +++ b/docx/oxml/xmlchemy.py @@ -188,8 +188,8 @@ def _setter(self): """ def set_attr_value(obj, value): if value is None or value == self._default: - if self._attr_name in obj.attrib: - del obj.attrib[self._attr_name] + if self._clark_name in obj.attrib: + del obj.attrib[self._clark_name] return str_value = self._simple_type.to_xml(value) obj.set(self._clark_name, str_value) diff --git a/tests/oxml/test_xmlchemy.py b/tests/oxml/test_xmlchemy.py index 59a730d26..407eaefc9 100644 --- a/tests/oxml/test_xmlchemy.py +++ b/tests/oxml/test_xmlchemy.py @@ -439,11 +439,14 @@ def getter_fixture(self): parent = a_parent().with_nsdecls().with_optAttr('24').element return parent, 24 - @pytest.fixture - def setter_fixture(self): + @pytest.fixture(params=[36, None]) + def setter_fixture(self, request): + value = request.param parent = a_parent().with_nsdecls().with_optAttr('42').element - value = 36 - expected_xml = a_parent().with_nsdecls().with_optAttr(value).xml() + if value is None: + expected_xml = a_parent().with_nsdecls().xml() + else: + expected_xml = a_parent().with_nsdecls().with_optAttr(value).xml() return parent, value, expected_xml @@ -731,7 +734,7 @@ class CT_Parent(BaseOxmlElement): oooChild = OneAndOnlyOne('w:oooChild') zomChild = ZeroOrMore('w:zomChild', successors=('w:zooChild',)) zooChild = ZeroOrOne('w:zooChild', successors=()) - optAttr = OptionalAttribute('optAttr', ST_IntegerType) + optAttr = OptionalAttribute('w:optAttr', ST_IntegerType) reqAttr = RequiredAttribute('reqAttr', ST_IntegerType) @@ -785,7 +788,7 @@ class CT_Choice2Builder(BaseBuilder): class CT_ParentBuilder(BaseBuilder): __tag__ = 'w:parent' __nspfxs__ = ('w',) - __attrs__ = ('optAttr', 'reqAttr') + __attrs__ = ('w:optAttr', 'reqAttr') class CT_OomChildBuilder(BaseBuilder): From 1b753b3a589402d4cf83f91ac18e8fe1c2132255 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 21:39:26 -0700 Subject: [PATCH 081/809] tbl: add _Column.width setter --- docx/shared.py | 2 +- docx/table.py | 4 ++++ features/steps/table.py | 4 +++- features/tbl-col-props.feature | 15 +++++++------- tests/test_table.py | 36 +++++++++++++++++++++++++++------- 5 files changed, 44 insertions(+), 17 deletions(-) diff --git a/docx/shared.py b/docx/shared.py index 9f791d328..2ea4cf474 100644 --- a/docx/shared.py +++ b/docx/shared.py @@ -62,7 +62,7 @@ def twips(self): """ The equivalent length expressed in twips (int). """ - return int(round(self / float(self._EMUS_PER_TWIP)) + 0.1) + return int(round(self / float(self._EMUS_PER_TWIP))) class Inches(Length): diff --git a/docx/table.py b/docx/table.py index b615c1ee0..c9e3fcf6f 100644 --- a/docx/table.py +++ b/docx/table.py @@ -140,6 +140,10 @@ def width(self): """ return self._gridCol.w + @width.setter + def width(self, value): + self._gridCol.w = value + class _ColumnCells(object): """ diff --git a/features/steps/table.py b/features/steps/table.py index e92b11619..3a8f31f98 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -287,7 +287,9 @@ def then_new_row_has_2_cells(context): @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 + assert context.column.width == expected_value, ( + 'got %s' % context.column.width + ) @then('the table style matches the name I applied') diff --git a/features/tbl-col-props.feature b/features/tbl-col-props.feature index 661746c31..30f5aaca5 100644 --- a/features/tbl-col-props.feature +++ b/features/tbl-col-props.feature @@ -14,16 +14,15 @@ Feature: Get and set table column widths | 1440 | 914400 | - @wip Scenario Outline: Set column width Given a table column having a width of When I set the column width to - Then the reported column width is + Then the reported column width is Examples: table column width values - | width | new-width | - | no explicit setting | None | - | no explicit setting | 914400 | - | 1440 | None | - | 1440 | 914400 | - | 1440 | 424242 | + | width | new-width | width-emu | + | no explicit setting | None | None | + | no explicit setting | 914400 | 914400 | + | 1440 | None | None | + | 1440 | 914400 | 914400 | + | 1440 | 424497 | 424180 | diff --git a/tests/test_table.py b/tests/test_table.py index e11c0ce6a..1b9f64fc6 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -168,10 +168,16 @@ def it_provides_access_to_the_column_cells(self, column): cells = column.cells assert isinstance(cells, _ColumnCells) - def it_knows_its_width_in_EMU(self, width_fixture): - column, expected_width = width_fixture + def it_knows_its_width_in_EMU(self, width_get_fixture): + column, expected_width = width_get_fixture assert column.width == expected_width + def it_can_change_its_width(self, width_set_fixture): + column, value, expected_xml = width_set_fixture + column.width = value + assert column.width == value + assert column._gridCol.xml == expected_xml + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -182,21 +188,37 @@ def it_knows_its_width_in_EMU(self, width_fixture): ('12.5pt', 158750), (None, None), ]) - def width_fixture(self, request): + def width_get_fixture(self, request): w, expected_width = request.param - gridCol_bldr = a_gridCol().with_nsdecls() - if w is not None: - gridCol_bldr.with_w(w) - gridCol = gridCol_bldr.element + gridCol = self.gridCol_bldr(w).element column = _Column(gridCol, None) return column, expected_width + @pytest.fixture(params=[ + (4242, None, None), + (None, None, None), + (4242, 914400, 1440), + (None, 914400, 1440), + ]) + def width_set_fixture(self, request): + initial_w, value, expected_w = request.param + gridCol = self.gridCol_bldr(initial_w).element + column = _Column(gridCol, None) + expected_xml = self.gridCol_bldr(expected_w).xml() + return column, value, expected_xml + # fixture components --------------------------------------------- @pytest.fixture def column(self): return _Column(None, None) + def gridCol_bldr(self, w=None): + gridCol_bldr = a_gridCol().with_nsdecls() + if w is not None: + gridCol_bldr.with_w(w) + return gridCol_bldr + class Describe_ColumnCells(object): From 15fbe518d89bbcb5199117bb4957b507e7e98096 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 17 Jun 2014 22:30:10 -0700 Subject: [PATCH 082/809] py3: fix Python 3 compatibility on xmlchemy --- docx/oxml/xmlchemy.py | 13 ++++++++++--- tests/oxml/test__init__.py | 18 +++++++----------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/docx/oxml/xmlchemy.py b/docx/oxml/xmlchemy.py index fdba5b8b6..40df33494 100644 --- a/docx/oxml/xmlchemy.py +++ b/docx/oxml/xmlchemy.py @@ -689,10 +689,12 @@ def _remove_choice_group_method_name(self): return '_remove_%s' % self._prop_name -class BaseOxmlElement(etree.ElementBase): +class _OxmlElementBase(etree.ElementBase): """ - Base class for all custom element classes, to add standardized behavior - to all classes in one place. + Effective base class for all custom element classes, to add standardized + behavior to all classes in one place. Actual inheritance is from + BaseOxmlElement below, needed to manage Python 2-3 metaclass declaration + compatibility. """ __metaclass__ = MetaOxmlElement @@ -752,3 +754,8 @@ def xpath(self, xpath_str): @property def _nsptag(self): return NamespacePrefixedTag.from_clark_name(self.tag) + + +BaseOxmlElement = MetaOxmlElement( + 'BaseOxmlElement', (etree.ElementBase,), dict(_OxmlElementBase.__dict__) +) diff --git a/tests/oxml/test__init__.py b/tests/oxml/test__init__.py index 3f7947653..4a4aab6b6 100644 --- a/tests/oxml/test__init__.py +++ b/tests/oxml/test__init__.py @@ -31,19 +31,15 @@ def it_adds_supplied_attributes(self): assert etree.tostring(element) == ( '' - ) + ).encode('utf-8') def it_adds_additional_namespace_declarations_when_supplied(self): - element = OxmlElement( - 'a:foo', nsdecls={ - 'a': 'http://schemas.openxmlformats.org/drawingml/2006/main', - 'x': 'other' - } - ) - assert etree.tostring(element) == ( - '' - ) + ns1 = 'http://schemas.openxmlformats.org/drawingml/2006/main' + ns2 = 'other' + element = OxmlElement('a:foo', nsdecls={'a': ns1, 'x': ns2}) + assert len(element.nsmap.items()) == 2 + assert element.nsmap['a'] == ns1 + assert element.nsmap['x'] == ns2 class DescribeOxmlParser(object): From 597617a105e392f89666f282690df2f938d5a83f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 18 Jun 2014 21:35:21 -0700 Subject: [PATCH 083/809] docs: document feature analysis for sections --- docs/dev/analysis/features/sections.rst | 192 +++++++++++++++++++--- docs/dev/analysis/schema/ct_document.rst | 199 +++++------------------ 2 files changed, 206 insertions(+), 185 deletions(-) diff --git a/docs/dev/analysis/features/sections.rst b/docs/dev/analysis/features/sections.rst index bfbd1ac01..77ed963f4 100644 --- a/docs/dev/analysis/features/sections.rst +++ b/docs/dev/analysis/features/sections.rst @@ -13,8 +13,8 @@ a ```` element in a run. Implementation notes -------------------- -It's probably not going to make a lot of sense to implement this before having -the ability to set at least a core subset of the section properties. First ones +Implementing adding a section break should probably wait until after the +ability to set at least a core subset of the section properties. First ones are probably: * page size @@ -33,19 +33,34 @@ I'm thinking the sequence is: Candidate protocol ------------------ -The following interactive session demonstrates the protocol for working with -sections:: +The following interactive session demonstrates the proposed protocol for +working with sections:: >>> sections = document.sections + >>> sections + >>> len(sections) 3 - >>> first_section = sections[0] - >>> last_section = sections[-1] - - >>> p = body.add_paragraph() - >>> p.section_properties - None - >>> p.add_section_break() + >>> section = sections[-1] # the sentinel section + >>> section + + >>> section.section_start + WD_SECTION.CONTINUOUS (0) + >>> page_setup = section.page_setup + >>> page_setup + + >>> page_setup.page_width + 7772400 # Inches(8.5) + >>> page_setup.page_height + 10058400 # Inches(11) + >>> page_setup.orientation + WD_ORIENT.PORTRAIT + >>> page_setup.left_margin # and .right_, .top_, .bottom_ + 914400 + >>> page_setup.header_distance # and .footer_distance + 457200 # Inches(0.5) + >>> page_setup.gutter + 0 Word behavior @@ -59,6 +74,73 @@ items are copied, but it at least includes the page size, margins, and column spacing. +Enumerations +------------ + +* `WdSectionStart Enumeration on MSDN`_ + +.. _WdSectionStart Enumeration on MSDN: + http://msdn.microsoft.com/en-us/library/office/bb238171.aspx + +:: + + @alias(WD_SECTION) + class WD_SECTION_START(Enumeration): + +CONTINUOUS (0) + Continuous section break. + +EVENPAGE (3) + Even pages section break. + +NEWCOLUMN (1) + New column section break. + +NEWPAGE (2) + New page section break. + +ODDPAGE (4) + Odd pages section break. + + +* `WdOrientation Enumeration on MSDN`_ + +.. _WdOrientation Enumeration on MSDN: + http://msdn.microsoft.com/en-us/library/office/ff837902.aspx + +:: + + @alias(WD_ORIENT) + class WD_ORIENTATION(Enumeration): + +LANDSCAPE (1) + Landscape orientation. + +PORTRAIT (0) + Portrait orientation. + +:: + + @alias(WD_SECTION) + class WD_SECTION_START(Enumeration): + +CONTINUOUS (0) + Continuous section break. + +EVENPAGE (3) + Even pages section break. + +NEWCOLUMN (1) + New column section break. + +NEWPAGE (2) + New page section break. + +ODDPAGE (4) + Odd pages section break. + + + Specimen XML ------------ @@ -131,7 +213,10 @@ Schema excerpt - + + + + @@ -153,19 +238,78 @@ Schema excerpt - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/dev/analysis/schema/ct_document.rst b/docs/dev/analysis/schema/ct_document.rst index 17756613b..137fed059 100644 --- a/docs/dev/analysis/schema/ct_document.rst +++ b/docs/dev/analysis/schema/ct_document.rst @@ -13,27 +13,6 @@ Spec Section , 17.2.3 -attributes ----------- - -=========== === =================== -name # type -=========== === =================== -conformance ? ST_ConformanceClass -=========== === =================== - - -child elements --------------- - -========== === ============= -name # type -========== === ============= -background ? CT_Background -body ? CT_Body -========== === ============= - - Spec text --------- @@ -53,83 +32,13 @@ Spec text document element. -RELAX NG Schema Excerpt ------------------------ - -:: - - w_CT_Body = - w_EG_BlockLevelElts*, - element sectPr { w_CT_SectPr }? - - w_EG_BlockLevelElts = - w_EG_BlockLevelChunkElts* - | element altChunk { w_CT_AltChunk }* - - w_EG_BlockLevelChunkElts = w_EG_ContentBlockContent* - - w_EG_ContentBlockContent = - element customXml { w_CT_CustomXmlBlock } - | element sdt { w_CT_SdtBlock } - | element p { w_CT_P }* - | element tbl { w_CT_Tbl }* - | w_EG_RunLevelElts* - - w_EG_RunLevelElts = - element proofErr { w_CT_ProofErr }? - | element permStart { w_CT_PermStart }? - | element permEnd { w_CT_Perm }? - | w_EG_RangeMarkupElements* - | element ins { w_CT_RunTrackChange }? - | element del { w_CT_RunTrackChange }? - | element moveFrom { w_CT_RunTrackChange } - | element moveTo { w_CT_RunTrackChange } - | w_EG_MathContent* - - w_EG_RangeMarkupElements = - element bookmarkStart { w_CT_Bookmark } - | element bookmarkEnd { w_CT_MarkupRange } - | element moveFromRangeStart { w_CT_MoveBookmark } - | element moveFromRangeEnd { w_CT_MarkupRange } - | element moveToRangeStart { w_CT_MoveBookmark } - | element moveToRangeEnd { w_CT_MarkupRange } - | element commentRangeStart { w_CT_MarkupRange } - | element commentRangeEnd { w_CT_MarkupRange } - | element customXmlInsRangeStart { w_CT_TrackChange } | element customXmlInsRangeEnd { w_CT_Markup } - | element customXmlDelRangeStart { w_CT_TrackChange } - | element customXmlDelRangeEnd { w_CT_Markup } - | element customXmlMoveFromRangeStart { w_CT_TrackChange } - | element customXmlMoveFromRangeEnd { w_CT_Markup } - | element customXmlMoveToRangeStart { w_CT_TrackChange } - | element customXmlMoveToRangeEnd { w_CT_Markup } - - w_EG_MathContent = m_oMathPara | m_oMath - - Schema excerpt -^^^^^^^^^^^^^^ +-------------- .. highlight:: xml :: - - - - - - - - - - - - - - - - - @@ -148,8 +57,10 @@ Schema excerpt - - + + @@ -159,74 +70,40 @@ Schema excerpt - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 6b0014a7a0985cf1c9dca549db3f8bb67e1e02aa Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 18 Jun 2014 22:51:42 -0700 Subject: [PATCH 084/809] acpt: add doc-access-sections.feature New version of flake8 caught some new PEP8 bits in conf.py --- docs/conf.py | 78 +++++++++--------- docx/parts/document.py | 7 ++ docx/section.py | 13 +++ features/doc-access-sections.feature | 18 ++++ features/steps/document.py | 63 ++++++++++++++ .../steps/test_files/doc-access-sections.docx | Bin 0 -> 25591 bytes 6 files changed, 142 insertions(+), 37 deletions(-) create mode 100644 docx/section.py create mode 100644 features/doc-access-sections.feature create mode 100644 features/steps/document.py create mode 100644 features/steps/test_files/doc-access-sections.docx diff --git a/docs/conf.py b/docs/conf.py index 3cb6d5ebe..13cba3cca 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,7 +26,7 @@ # -- General configuration --------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. @@ -45,7 +45,7 @@ source_suffix = '.rst' # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' @@ -113,6 +113,10 @@ .. |Run| replace:: :class:`Run` +.. |Section| replace:: :class:`.Section` + +.. |Sections| replace:: :class:`.Sections` + .. |StylesPart| replace:: :class:`StylesPart` .. |Table| replace:: :class:`.Table` @@ -131,24 +135,24 @@ # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output ------------------------------------------------ @@ -160,26 +164,26 @@ # 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 # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. html_theme_path = ['_themes'] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # 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, @@ -188,14 +192,14 @@ # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} html_sidebars = { '**': ['localtoc.html', 'relations.html', 'sidebarlinks.html', 'searchbox.html'] @@ -203,33 +207,33 @@ # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'python-docxdoc' @@ -239,13 +243,13 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). - #'papersize': 'letterpaper', + # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). - #'pointsize': '10pt', + # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. - #'preamble': '', + # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples @@ -261,23 +265,23 @@ # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output ----------------------------------------- @@ -290,7 +294,7 @@ ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output --------------------------------------------- @@ -305,13 +309,13 @@ ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # Example configuration for intersphinx: refer to the Python standard library. diff --git a/docx/parts/document.py b/docx/parts/document.py index e9d520724..02582f2b2 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -214,3 +214,10 @@ def _inline_lst(self): body = self._body xpath = './w:p/w:r/w:drawing/wp:inline' return body.xpath(xpath) + + +class Sections(object): + """ + Sequence of |Section| objects corresponding to the sections in the + document. + """ diff --git a/docx/section.py b/docx/section.py new file mode 100644 index 000000000..c869c88c4 --- /dev/null +++ b/docx/section.py @@ -0,0 +1,13 @@ +# encoding: utf-8 + +""" +The |Section| object and related proxy classes. +""" + +from __future__ import absolute_import, print_function, unicode_literals + + +class Section(object): + """ + Document section, providing access to section and page setup settings. + """ diff --git a/features/doc-access-sections.feature b/features/doc-access-sections.feature new file mode 100644 index 000000000..3aede0b3f --- /dev/null +++ b/features/doc-access-sections.feature @@ -0,0 +1,18 @@ +Feature: Access document sections + In order to discover and apply section-level settings + As a developer using python-docx + I need a way to access document sections + + + @wip + Scenario: Access section collection of a document + Given a document having three sections + Then I can access the section collection of the document + And the length of the section collection is 3 + + + @wip + Scenario: Access section in section collection + Given a section collection + Then I can iterate over the sections + And I can access a section by index diff --git a/features/steps/document.py b/features/steps/document.py new file mode 100644 index 000000000..411c95947 --- /dev/null +++ b/features/steps/document.py @@ -0,0 +1,63 @@ +# encoding: utf-8 + +""" +Step implementations for document-related features +""" + +from __future__ import absolute_import, print_function, unicode_literals + +from behave import given, then + +from docx import Document +from docx.parts.document import Sections +from docx.section import Section + +from helpers import test_docx + + +# given =================================================== + +@given('a document having three sections') +def given_a_document_having_three_sections(context): + context.document = Document(test_docx('doc-access-sections')) + + +@given('a section collection') +def given_a_section_collection(context): + document = Document(test_docx('doc-access-sections')) + context.sections = document.sections + + +# then ==================================================== + +@then('I can access a section by index') +def then_I_can_access_a_section_by_index(context): + sections = context.sections + for idx in range(3): + section = sections[idx] + assert isinstance(section, Section) + + +@then('I can access the section collection of the document') +def then_I_can_access_the_section_collection_of_the_document(context): + sections = context.document.sections + msg = 'document.sections not instance of Sections' + assert isinstance(sections, Sections), msg + + +@then('I can iterate over the sections') +def then_I_can_iterate_over_the_sections(context): + sections = context.sections + actual_count = 0 + for section in sections: + actual_count += 1 + assert isinstance(section, Section) + assert actual_count == 3 + + +@then('the length of the section collection is 3') +def then_the_length_of_the_section_collection_is_3(context): + sections = context.sections + assert len(sections) == 3, ( + 'expected len(sections) of 2, got %s' % len(sections) + ) diff --git a/features/steps/test_files/doc-access-sections.docx b/features/steps/test_files/doc-access-sections.docx new file mode 100644 index 0000000000000000000000000000000000000000..6a4bf670ba9194a9b9c264eda94ca44dbeee9413 GIT binary patch literal 25591 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 Date: Wed, 18 Jun 2014 23:16:20 -0700 Subject: [PATCH 085/809] sect: add Document.sections and DocumentPart.sections --- docx/api.py | 7 +++++++ docx/parts/document.py | 10 ++++++++++ features/steps/document.py | 2 +- tests/parts/test_document.py | 16 ++++++++++++++++ tests/test_api.py | 4 ++++ 5 files changed, 38 insertions(+), 1 deletion(-) diff --git a/docx/api.py b/docx/api.py index 88e97c115..6e9b72bac 100644 --- a/docx/api.py +++ b/docx/api.py @@ -147,6 +147,13 @@ def save(self, path_or_stream): """ self._package.save(path_or_stream) + @property + def sections(self): + """ + Return a reference to the |Sections| instance for this document. + """ + return self._document_part.sections + @lazyproperty def styles_part(self): """ diff --git a/docx/parts/document.py b/docx/parts/document.py index 02582f2b2..ab7930b58 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -110,6 +110,13 @@ def part(self): """ return self + @lazyproperty + def sections(self): + """ + The |Sections| instance organizing the sections in this document. + """ + return Sections(self._element) + @property def tables(self): """ @@ -221,3 +228,6 @@ class Sections(object): Sequence of |Section| objects corresponding to the sections in the document. """ + def __init__(self, document_elm): + super(Sections, self).__init__() + self._document_elm = document_elm diff --git a/features/steps/document.py b/features/steps/document.py index 411c95947..33b873d41 100644 --- a/features/steps/document.py +++ b/features/steps/document.py @@ -57,7 +57,7 @@ def then_I_can_iterate_over_the_sections(context): @then('the length of the section collection is 3') def then_the_length_of_the_section_collection_is_3(context): - sections = context.sections + sections = context.document.sections assert len(sections) == 3, ( 'expected len(sections) of 2, got %s' % len(sections) ) diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index e4dcd1549..9cce0de6d 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -113,6 +113,12 @@ def it_provides_access_to_the_document_paragraphs( paragraphs = document_part.paragraphs assert paragraphs is paragraphs_ + def it_provides_access_to_the_document_sections(self, sections_fixture): + document, document_elm, Sections_ = sections_fixture + sections = document.sections + Sections_.assert_called_once_with(document_elm) + assert sections is Sections_.return_value + def it_provides_access_to_the_document_tables(self, tables_fixture): document_part, tables_ = tables_fixture tables = document_part.tables @@ -300,6 +306,16 @@ def relate_to_(self, request, rId_): def rId_(self, request): return instance_mock(request, str) + @pytest.fixture + def Sections_(self, request): + return class_mock(request, 'docx.parts.document.Sections') + + @pytest.fixture + def sections_fixture(self, request, Sections_): + document_elm = a_document().with_nsdecls().element + document = DocumentPart(None, None, document_elm, None) + return document, document_elm, Sections_ + @pytest.fixture def serialize_part_xml_(self, request): return function_mock( diff --git a/tests/test_api.py b/tests/test_api.py index 5f22a258b..1adc1ebab 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -115,6 +115,10 @@ def it_provides_access_to_the_document_paragraphs( paragraphs = document.paragraphs assert paragraphs is paragraphs_ + def it_provides_access_to_the_document_sections(self, document): + body = document.sections + assert body is document._document_part.sections + def it_provides_access_to_the_document_tables(self, tables_fixture): document, tables_ = tables_fixture tables = document.tables From 6469b5a8e87483be79010be86ce0555a41528645 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 18 Jun 2014 23:57:15 -0700 Subject: [PATCH 086/809] sect: add Sections.__len__() --- docx/oxml/parts/document.py | 8 ++++++++ docx/parts/document.py | 10 +++++++++- features/doc-access-sections.feature | 1 - tests/parts/test_document.py | 27 +++++++++++++++++++++++++-- 4 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docx/oxml/parts/document.py b/docx/oxml/parts/document.py index 3588fe6df..59c1fa165 100644 --- a/docx/oxml/parts/document.py +++ b/docx/oxml/parts/document.py @@ -16,6 +16,14 @@ class CT_Document(BaseOxmlElement): """ body = ZeroOrOne('w:body') + @property + 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') + class CT_Body(BaseOxmlElement): """ diff --git a/docx/parts/document.py b/docx/parts/document.py index ab7930b58..7c7c7f2b7 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -8,6 +8,8 @@ absolute_import, division, print_function, unicode_literals ) +from collections import Sequence + from ..opc.constants import RELATIONSHIP_TYPE as RT from ..opc.oxml import serialize_part_xml from ..opc.package import Part @@ -223,7 +225,7 @@ def _inline_lst(self): return body.xpath(xpath) -class Sections(object): +class Sections(Sequence): """ Sequence of |Section| objects corresponding to the sections in the document. @@ -231,3 +233,9 @@ class Sections(object): def __init__(self, document_elm): super(Sections, self).__init__() self._document_elm = document_elm + + def __getitem__(self, key): + pass + + def __len__(self): + return len(self._document_elm.sectPr_lst) diff --git a/features/doc-access-sections.feature b/features/doc-access-sections.feature index 3aede0b3f..65839039a 100644 --- a/features/doc-access-sections.feature +++ b/features/doc-access-sections.feature @@ -4,7 +4,6 @@ Feature: Access document sections I need a way to access document sections - @wip Scenario: Access section collection of a document Given a document having three sections Then I can access the section collection of the document diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 9cce0de6d..d608473ee 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -16,7 +16,7 @@ from docx.oxml.parts.document import CT_Body, CT_Document from docx.oxml.text import CT_R from docx.package import ImageParts, Package -from docx.parts.document import _Body, DocumentPart, InlineShapes +from docx.parts.document import _Body, DocumentPart, InlineShapes, Sections from docx.parts.image import ImagePart from docx.shape import InlineShape from docx.table import Table @@ -27,7 +27,7 @@ from ..oxml.unitdata.table import ( a_gridCol, a_tbl, a_tblGrid, a_tblPr, a_tblW, a_tc, a_tr ) -from ..oxml.unitdata.text import a_p, a_sectPr, an_r +from ..oxml.unitdata.text import a_p, a_pPr, a_sectPr, an_r from ..unitutil import ( function_mock, class_mock, initializer_mock, instance_mock, loose_mock, method_mock, property_mock @@ -617,3 +617,26 @@ def rId_(self, request): @pytest.fixture def shape_id_(self, request): return instance_mock(request, int) + + +class DescribeSections(object): + + def it_knows_how_many_sections_it_contains(self, len_fixture): + sections, expected_len = len_fixture + print(sections._document_elm.xml) + assert len(sections) == expected_len + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def len_fixture(self): + document_elm = ( + a_document().with_nsdecls().with_child( + a_body().with_child( + a_p().with_child( + a_pPr().with_child( + a_sectPr()))).with_child( + a_sectPr())) + ).element + sections = Sections(document_elm) + return sections, 2 From e7fb78a561c0f5103a6eae06bd802330dd39cf8a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 19 Jun 2014 00:10:04 -0700 Subject: [PATCH 087/809] sect: add Sections.__iter__() --- docx/parts/document.py | 5 +++++ docx/section.py | 3 +++ tests/parts/test_document.py | 27 +++++++++++++++++++++++---- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/docx/parts/document.py b/docx/parts/document.py index 7c7c7f2b7..675bf225a 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -14,6 +14,7 @@ from ..opc.oxml import serialize_part_xml from ..opc.package import Part from ..oxml import parse_xml +from ..section import Section from ..shape import InlineShape from ..shared import lazyproperty, Parented from ..table import Table @@ -237,5 +238,9 @@ def __init__(self, document_elm): def __getitem__(self, key): pass + def __iter__(self): + for sectPr in self._document_elm.sectPr_lst: + yield Section(sectPr) + def __len__(self): return len(self._document_elm.sectPr_lst) diff --git a/docx/section.py b/docx/section.py index c869c88c4..dd0991134 100644 --- a/docx/section.py +++ b/docx/section.py @@ -11,3 +11,6 @@ class Section(object): """ Document section, providing access to section and page setup settings. """ + def __init__(self, sectPr): + super(Section, self).__init__() + self._sectPr = sectPr diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index d608473ee..4f0c8e41a 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -18,6 +18,7 @@ from docx.package import ImageParts, Package from docx.parts.document import _Body, DocumentPart, InlineShapes, Sections from docx.parts.image import ImagePart +from docx.section import Section from docx.shape import InlineShape from docx.table import Table from docx.text import Paragraph @@ -626,11 +627,31 @@ def it_knows_how_many_sections_it_contains(self, len_fixture): print(sections._document_elm.xml) assert len(sections) == expected_len + def it_can_iterate_over_its_Section_instances(self, iter_fixture): + sections, expected_count = iter_fixture + section_count = 0 + for section in sections: + section_count += 1 + assert isinstance(section, Section) + assert section_count == expected_count + # fixtures ------------------------------------------------------- @pytest.fixture - def len_fixture(self): - document_elm = ( + def iter_fixture(self, document_elm): + sections = Sections(document_elm) + return sections, 2 + + @pytest.fixture + def len_fixture(self, document_elm): + sections = Sections(document_elm) + return sections, 2 + + # fixture components --------------------------------------------- + + @pytest.fixture + def document_elm(self): + return ( a_document().with_nsdecls().with_child( a_body().with_child( a_p().with_child( @@ -638,5 +659,3 @@ def len_fixture(self): a_sectPr()))).with_child( a_sectPr())) ).element - sections = Sections(document_elm) - return sections, 2 From e0537246c0d93b002218c90f1182578f018b9336 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 19 Jun 2014 00:24:29 -0700 Subject: [PATCH 088/809] sect: add Sections.__getitem__() --- docx/parts/document.py | 6 +++++- features/doc-access-sections.feature | 1 - tests/parts/test_document.py | 11 +++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/docx/parts/document.py b/docx/parts/document.py index 675bf225a..42c18c1fe 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -236,7 +236,11 @@ def __init__(self, document_elm): self._document_elm = document_elm def __getitem__(self, key): - pass + if isinstance(key, slice): + sectPr_lst = self._document_elm.sectPr_lst[key] + return [Section(sectPr) for sectPr in sectPr_lst] + sectPr = self._document_elm.sectPr_lst[key] + return Section(sectPr) def __iter__(self): for sectPr in self._document_elm.sectPr_lst: diff --git a/features/doc-access-sections.feature b/features/doc-access-sections.feature index 65839039a..8cb836c42 100644 --- a/features/doc-access-sections.feature +++ b/features/doc-access-sections.feature @@ -10,7 +10,6 @@ Feature: Access document sections And the length of the section collection is 3 - @wip Scenario: Access section in section collection Given a section collection Then I can iterate over the sections diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 4f0c8e41a..e1b11278d 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -635,8 +635,19 @@ def it_can_iterate_over_its_Section_instances(self, iter_fixture): assert isinstance(section, Section) assert section_count == expected_count + def it_can_access_its_Section_instances_by_index(self, index_fixture): + sections, indicies = index_fixture + assert len(sections[0:2]) == 2 + for index in indicies: + assert isinstance(sections[index], Section) + # fixtures ------------------------------------------------------- + @pytest.fixture + def index_fixture(self, document_elm): + sections = Sections(document_elm) + return sections, [0, 1] + @pytest.fixture def iter_fixture(self, document_elm): sections = Sections(document_elm) From f5e4d3ee450d76954b5d78683767800d9cf38a77 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 20 Jun 2014 00:39:24 -0700 Subject: [PATCH 089/809] acpt: add act-section-props.feature --- docs/dev/analysis/features/sections.rst | 23 +--------- docx/enum/section.py | 38 ++++++++++++++++ features/sct-section-props.feature | 18 ++++++++ features/steps/section.py | 43 ++++++++++++++++++ .../steps/test_files/sct-section-props.docx | Bin 0 -> 27763 bytes 5 files changed, 100 insertions(+), 22 deletions(-) create mode 100644 docx/enum/section.py create mode 100644 features/sct-section-props.feature create mode 100644 features/steps/section.py create mode 100644 features/steps/test_files/sct-section-props.docx diff --git a/docs/dev/analysis/features/sections.rst b/docs/dev/analysis/features/sections.rst index 77ed963f4..ba3ba0a75 100644 --- a/docs/dev/analysis/features/sections.rst +++ b/docs/dev/analysis/features/sections.rst @@ -38,7 +38,7 @@ working with sections:: >>> sections = document.sections >>> sections - + >>> len(sections) 3 >>> section = sections[-1] # the sentinel section @@ -119,27 +119,6 @@ LANDSCAPE (1) PORTRAIT (0) Portrait orientation. -:: - - @alias(WD_SECTION) - class WD_SECTION_START(Enumeration): - -CONTINUOUS (0) - Continuous section break. - -EVENPAGE (3) - Even pages section break. - -NEWCOLUMN (1) - New column section break. - -NEWPAGE (2) - New page section break. - -ODDPAGE (4) - Odd pages section break. - - Specimen XML ------------ diff --git a/docx/enum/section.py b/docx/enum/section.py new file mode 100644 index 000000000..d586c20c0 --- /dev/null +++ b/docx/enum/section.py @@ -0,0 +1,38 @@ +# encoding: utf-8 + +""" +Enumerations related to the main document in WordprocessingML files +""" + +from __future__ import absolute_import, print_function, unicode_literals + +from .base import alias, XmlEnumeration, XmlMappedEnumMember + + +@alias('WD_SECTION') +class WD_SECTION_START(XmlEnumeration): + """ + Specifies the start type of a section break. + """ + + __ms_name__ = 'WdSectionStart' + + __url__ = 'http://msdn.microsoft.com/en-us/library/office/ff840975.aspx' + + __members__ = ( + XmlMappedEnumMember( + 'CONTINUOUS', 0, 'continuous', 'Continuous section break.' + ), + XmlMappedEnumMember( + 'NEW_COLUMN', 1, 'nextColumn', 'New column section break.' + ), + XmlMappedEnumMember( + 'NEW_PAGE', 2, 'nextPage', 'New page section break.' + ), + XmlMappedEnumMember( + 'EVEN_PAGE', 3, 'evenPage', 'Even pages section break.' + ), + XmlMappedEnumMember( + 'ODD_PAGE', 4, 'oddPage', 'Section begins on next odd page.' + ), + ) diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature new file mode 100644 index 000000000..f4ed6406c --- /dev/null +++ b/features/sct-section-props.feature @@ -0,0 +1,18 @@ +Feature: Access and change section properties + In order to discover and modify document section behaviors + As a developer using python-docx + I need a way to get and set the properties of a section + + + @wip + Scenario Outline: Get section start type + Given a section having start type + Then the reported section start type is + + Examples: Section start types + | start-type | + | CONTINUOUS | + | NEW_COLUMN | + | NEW_PAGE | + | EVEN_PAGE | + | ODD_PAGE | diff --git a/features/steps/section.py b/features/steps/section.py new file mode 100644 index 000000000..30e4aaa8f --- /dev/null +++ b/features/steps/section.py @@ -0,0 +1,43 @@ +# encoding: utf-8 + +""" +Step implementations for section-related features +""" + +from __future__ import absolute_import, print_function, unicode_literals + +from behave import given, then + +from docx import Document +from docx.enum.section import WD_SECTION + +from helpers import test_docx + + +# given ==================================================== + +@given('a section having start type {start_type}') +def given_a_section_having_start_type(context, start_type): + section_idx = { + 'CONTINUOUS': 0, + 'NEW_PAGE': 1, + 'ODD_PAGE': 2, + 'EVEN_PAGE': 3, + 'NEW_COLUMN': 4, + }[start_type] + document = Document(test_docx('sct-section-props')) + context.section = document.sections[section_idx] + + +# then ===================================================== + +@then('the reported section start type is {start_type}') +def then_the_reported_section_start_type_is_type(context, start_type): + expected_start_type = { + 'CONTINUOUS': WD_SECTION.CONTINUOUS, + 'EVEN_PAGE': WD_SECTION.EVEN_PAGE, + 'NEW_COLUMN': WD_SECTION.NEW_COLUMN, + 'NEW_PAGE': WD_SECTION.NEW_PAGE, + 'ODD_PAGE': WD_SECTION.ODD_PAGE, + }[start_type] + assert context.section.start_type == expected_start_type diff --git a/features/steps/test_files/sct-section-props.docx b/features/steps/test_files/sct-section-props.docx new file mode 100644 index 0000000000000000000000000000000000000000..c29c10e8142869f605831cb09e370c327163e839 GIT binary patch literal 27763 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

sI0%$yZVQxW5kY+gYKw=4A)#>!?*%s=>rX^? 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

|nqvFv(O|e!^&qg?1Quy*n)XR=Y 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 Date: Sat, 13 Dec 2014 13:47:45 -0800 Subject: [PATCH 237/809] docs: document CoreProperties analysis --- docs/dev/analysis/features/coreprops.rst | 199 +++++++++++++++++++++++ docs/dev/analysis/index.rst | 1 + 2 files changed, 200 insertions(+) create mode 100644 docs/dev/analysis/features/coreprops.rst diff --git a/docs/dev/analysis/features/coreprops.rst b/docs/dev/analysis/features/coreprops.rst new file mode 100644 index 000000000..a5b2c47d5 --- /dev/null +++ b/docs/dev/analysis/features/coreprops.rst @@ -0,0 +1,199 @@ + +Core Document Properties +======================== + +The Open XML format provides for a set of descriptive properties to be +maintained with each document. One of these is the *core file properties*. +The core properties are common to all Open XML formats and appear in +document, presentation, and spreadsheet files. The 'Core' in core document +properties refers to `Dublin Core`_, a metadata standard that defines a core +set of elements to describe resources. + +The core properties are described in Part 2 of the ISO/IEC 29500 spec, in +Section 11. The names of some core properties in |docx| are changed from +those in the spec to conform to the MS API. + +Other properties such as company name are custom properties, held in +``app.xml``. + + +Candidate Protocol +------------------ + +:: + + >>> document = Document() + >>> core_properties = document.core_properties + >>> core_properties.author + 'python-docx' + >>> core_properties.author = 'Brian' + >>> core_properties.author + 'Brian' + + +Properties +---------- + +15 properties are supported. All unicode values are limited to 255 characters +(not bytes). + +author *(unicode)* + Note: named 'creator' in spec. An entity primarily responsible for making + the content of the resource. (Dublin Core) + +category *(unicode)* + A categorization of the content of this package. Example values for this + property might include: Resume, Letter, Financial Forecast, Proposal, + Technical Presentation, and so on. (Open Packaging Conventions) + +comments *(unicode)* + Note: named 'description' in spec. An explanation of the content of the + resource. Values might include an abstract, table of contents, reference + to a graphical representation of content, and a free-text account of the + content. (Dublin Core) + +content_status *(unicode)* + The status of the content. Values might include “Draft”, “Reviewed”, and + “Final”. (Open Packaging Conventions) + +created *(datetime)* + Date of creation of the resource. (Dublin Core) + +identifier *(unicode)* + An unambiguous reference to the resource within a given context. + (Dublin Core) + +keywords *(unicode)* + A delimited set of keywords to support searching and indexing. This is + typically a list of terms that are not available elsewhere in the + properties. (Open Packaging Conventions) + +language *(unicode)* + The language of the intellectual content of the resource. (Dublin Core) + +last_modified_by *(unicode)* + The user who performed the last modification. The identification is + environment-specific. Examples include a name, email address, or employee + ID. It is recommended that this value be as concise as possible. + (Open Packaging Conventions) + +last_printed *(datetime)* + The date and time of the last printing. (Open Packaging Conventions) + +modified *(datetime)* + Date on which the resource was changed. (Dublin Core) + +revision *(int)* + The revision number. This value might indicate the number of saves or + revisions, provided the application updates it after each revision. + (Open Packaging Conventions) + +subject *(unicode)* + The topic of the content of the resource. (Dublin Core) + +title *(unicode)* + The name given to the resource. (Dublin Core) + +version *(unicode)* + The version designator. This value is set by the user or by the + application. (Open Packaging Conventions) + + +Specimen XML +------------ + +.. highlight:: xml + +core.xml produced by Microsoft Word:: + + + + Core Document Properties Exploration + PowerPoint core document properties + Steve Canny + powerpoint; open xml; dublin core; microsoft office + + One thing I'd like to discover is just how line wrapping is handled + in the comments. This paragraph is all on a single + line._x000d__x000d_This is a second paragraph separated from the + first by two line feeds. + + Steve Canny + 2 + 2013-04-06T06:03:36Z + 2013-06-15T06:09:18Z + analysis + + + +Schema +====== + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +.. _Dublin Core: + http://en.wikipedia.org/wiki/Dublin_Core diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index 49cdeda8e..b17d7524e 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,7 @@ Feature Analysis .. toctree:: :maxdepth: 1 + features/coreprops features/cell-merge features/table features/table-props From 783cf9ff1068854026fd490f4eb48ad2bfbb3b5f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 14:28:40 -0800 Subject: [PATCH 238/809] acpt: add scenario for reading CoreProperties --- docx/opc/coreprops.py | 17 ++++++ features/doc-coreprops.feature | 10 +++ features/steps/coreprops.py | 61 +++++++++++++++++++ features/steps/test_files/doc-coreprops.docx | Bin 0 -> 11992 bytes 4 files changed, 88 insertions(+) create mode 100644 docx/opc/coreprops.py create mode 100644 features/doc-coreprops.feature create mode 100644 features/steps/coreprops.py create mode 100644 features/steps/test_files/doc-coreprops.docx diff --git a/docx/opc/coreprops.py b/docx/opc/coreprops.py new file mode 100644 index 000000000..cc8091ef3 --- /dev/null +++ b/docx/opc/coreprops.py @@ -0,0 +1,17 @@ +# encoding: utf-8 + +""" +The :mod:`pptx.packaging` module coheres around the concerns of reading and +writing presentations to and from a .pptx file. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + + +class CoreProperties(object): + """ + Corresponds to part named ``/docProps/core.xml``, containing the core + document properties for this document package. + """ diff --git a/features/doc-coreprops.feature b/features/doc-coreprops.feature new file mode 100644 index 000000000..0d84b9862 --- /dev/null +++ b/features/doc-coreprops.feature @@ -0,0 +1,10 @@ +Feature: Read and write core document properties + In order to find documents and make them manageable by digital means + As a developer using python-docx + I need to access and modify the Dublin Core metadata for a document + + @wip + Scenario: read the core properties of a document + Given a document having known core properties + Then I can access the core properties object + And the core property values match the known values diff --git a/features/steps/coreprops.py b/features/steps/coreprops.py new file mode 100644 index 000000000..41767b8b6 --- /dev/null +++ b/features/steps/coreprops.py @@ -0,0 +1,61 @@ +# encoding: utf-8 + +""" +Gherkin step implementations for core properties-related features. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from datetime import datetime + +from behave import given, then + +from docx import Document +from docx.opc.coreprops import CoreProperties + +from helpers import test_docx + + +# given =================================================== + +@given('a document having known core properties') +def given_a_document_having_known_core_properties(context): + context.document = Document(test_docx('doc-coreprops')) + + +# then ==================================================== + +@then('I can access the core properties object') +def then_I_can_access_the_core_properties_object(context): + document = context.document + core_properties = document.core_properties + assert isinstance(core_properties, CoreProperties) + + +@then('the core property values match the known values') +def then_the_core_property_values_match_the_known_values(context): + known_propvals = ( + ('author', 'Steve Canny'), + ('category', 'Category'), + ('comments', 'Description'), + ('content_status', 'Content Status'), + ('created', datetime(2014, 12, 13, 22, 2, 0)), + ('identifier', 'Identifier'), + ('keywords', 'key; word; keyword'), + ('language', 'Language'), + ('last_modified_by', 'Steve Canny'), + ('last_printed', datetime(2014, 12, 13, 22, 2, 42)), + ('modified', datetime(2014, 12, 13, 22, 6, 0)), + ('revision', 2), + ('subject', 'Subject'), + ('title', 'Title'), + ('version', '0.7.1a3'), + ) + core_properties = context.document.core_properties + for name, expected_value in known_propvals: + value = getattr(core_properties, name) + assert value == expected_value, ( + "got '%s' for core property '%s'" % (value, name) + ) diff --git a/features/steps/test_files/doc-coreprops.docx b/features/steps/test_files/doc-coreprops.docx new file mode 100644 index 0000000000000000000000000000000000000000..78ef3f56a4cd6be850caba3cc269d326e48744c8 GIT binary patch literal 11992 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 R5^JDuMNlQro&NW${{yvinSTHP literal 0 HcmV?d00001 From 652fc43011a0b45f4eb1ba3c0952541e0a6d279c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 14:55:07 -0800 Subject: [PATCH 239/809] doc: add Document.core_properties --- docs/conf.py | 2 ++ docx/api.py | 8 ++++++++ docx/opc/package.py | 8 ++++++++ tests/test_api.py | 15 +++++++++++++++ 4 files changed, 33 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 8dac74384..46f243600 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -77,6 +77,8 @@ .. |_Columns| replace:: :class:`_Columns` +.. |CoreProperties| replace:: :class:`.CoreProperties` + .. |Document| replace:: :class:`.Document` .. |docx| replace:: ``python-docx`` diff --git a/docx/api.py b/docx/api.py index c1ac093b7..74eaea557 100644 --- a/docx/api.py +++ b/docx/api.py @@ -108,6 +108,14 @@ def add_table(self, rows, cols, style='LightShading-Accent1'): table.style = style return table + @property + def core_properties(self): + """ + A |CoreProperties| object providing read/write access to the core + properties of this document. + """ + return self._package.core_properties + @property def inline_shapes(self): """ diff --git a/docx/opc/package.py b/docx/opc/package.py index 6c44453ce..a50e90d9e 100644 --- a/docx/opc/package.py +++ b/docx/opc/package.py @@ -35,6 +35,14 @@ def after_unmarshal(self): # subclass pass + @property + def core_properties(self): + """ + |CoreProperties| object providing read/write access to the Dublin + Core properties for this document. + """ + raise NotImplementedError + def iter_rels(self): """ Generate exactly one reference to each relationship in the package by diff --git a/tests/test_api.py b/tests/test_api.py index 9d7fcfc51..ecf084a9b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -13,6 +13,7 @@ from docx.api import Document from docx.enum.text import WD_BREAK from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT +from docx.opc.coreprops import CoreProperties from docx.package import Package from docx.parts.document import DocumentPart, InlineShapes from docx.parts.numbering import NumberingPart @@ -131,6 +132,11 @@ def it_can_save_the_package(self, save_fixture): document.save(file_) package_.save.assert_called_once_with(file_) + def it_provides_access_to_the_core_properties(self, core_props_fixture): + document, core_properties_ = core_props_fixture + core_properties = document.core_properties + assert core_properties is core_properties_ + def it_provides_access_to_the_numbering_part(self, num_part_get_fixture): document, document_part_, numbering_part_ = num_part_get_fixture numbering_part = document.numbering_part @@ -214,6 +220,11 @@ def add_table_fixture(self, request, document, document_part_, table_): table_ ) + @pytest.fixture + def core_props_fixture(self, document, core_properties_): + document._package.core_properties = core_properties_ + return document, core_properties_ + @pytest.fixture def init_fixture(self, docx_, open_): return docx_, open_ @@ -249,6 +260,10 @@ def add_paragraph_(self, request, paragraph_): request, Document, 'add_paragraph', return_value=paragraph_ ) + @pytest.fixture + def core_properties_(self, request): + return instance_mock(request, CoreProperties) + @pytest.fixture def default_docx_(self, request): return var_mock(request, 'docx.api._default_docx_path') From da0587c6a9c12c61849e984c3d445498fa556eae Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 15:22:26 -0800 Subject: [PATCH 240/809] opc: add OpcPackage.core_properties Also, organize fixture components separate from fixtures. --- docx/opc/package.py | 10 +++++- docx/opc/parts/__init__.py | 0 docx/opc/parts/coreprops.py | 25 ++++++++++++++ tests/opc/test_package.py | 67 +++++++++++++++++++++++++++---------- 4 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 docx/opc/parts/__init__.py create mode 100644 docx/opc/parts/coreprops.py diff --git a/docx/opc/package.py b/docx/opc/package.py index a50e90d9e..bcfeb6a15 100644 --- a/docx/opc/package.py +++ b/docx/opc/package.py @@ -41,7 +41,7 @@ def core_properties(self): |CoreProperties| object providing read/write access to the Dublin Core properties for this document. """ - raise NotImplementedError + return self._core_properties_part.core_properties def iter_rels(self): """ @@ -159,6 +159,14 @@ def save(self, pkg_file): part.before_marshal() PackageWriter.write(pkg_file, self.rels, self.parts) + @property + def _core_properties_part(self): + """ + |CorePropertiesPart| object related to this package. Creates + a default core properties part if one is not present (not common). + """ + raise NotImplementedError + class Part(object): """ diff --git a/docx/opc/parts/__init__.py b/docx/opc/parts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docx/opc/parts/coreprops.py b/docx/opc/parts/coreprops.py new file mode 100644 index 000000000..748e49cde --- /dev/null +++ b/docx/opc/parts/coreprops.py @@ -0,0 +1,25 @@ +# encoding: utf-8 + +""" +Core properties part, corresponds to ``/docProps/core.xml`` part in package. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from ..package import XmlPart + + +class CorePropertiesPart(XmlPart): + """ + Corresponds to part named ``/docProps/core.xml``, containing the core + document properties for this document package. + """ + @property + def core_properties(self): + """ + A |CoreProperties| object providing read/write access to the core + properties contained in this core properties part. + """ + raise NotImplementedError diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index aa0eff574..71d181e58 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -8,19 +8,22 @@ import pytest +from docx.opc.coreprops import CoreProperties from docx.opc.oxml import CT_Relationships from docx.opc.packuri import PACKAGE_URI, PackURI from docx.opc.package import ( OpcPackage, Part, PartFactory, _Relationship, Relationships, Unmarshaller, XmlPart ) +from docx.opc.parts.coreprops import CorePropertiesPart from docx.opc.pkgreader import PackageReader from docx.oxml.xmlchemy import BaseOxmlElement from ..unitutil.cxml import element from ..unitutil.mock import ( call, class_mock, cls_attr_mock, function_mock, initializer_mock, - instance_mock, loose_mock, method_mock, Mock, patch, PropertyMock + instance_mock, loose_mock, method_mock, Mock, patch, PropertyMock, + property_mock ) @@ -113,8 +116,53 @@ def it_can_save_to_a_pkg_file( pkg_file_, pkg._rels, parts_ ) + def it_provides_access_to_the_core_properties(self, core_props_fixture): + opc_package, core_properties_ = core_props_fixture + core_properties = opc_package.core_properties + assert core_properties is core_properties_ + # fixtures --------------------------------------------- + @pytest.fixture + def core_props_fixture( + self, _core_properties_part_prop_, core_properties_part_, + core_properties_): + opc_package = OpcPackage() + _core_properties_part_prop_.return_value = core_properties_part_ + core_properties_part_.core_properties = core_properties_ + return opc_package, core_properties_ + + @pytest.fixture + def relate_to_part_fixture_(self, request, pkg, rels_, reltype): + rId = 'rId99' + rel_ = instance_mock(request, _Relationship, name='rel_', rId=rId) + rels_.get_or_add.return_value = rel_ + pkg._rels = rels_ + part_ = instance_mock(request, Part, name='part_') + return pkg, part_, reltype, rId + + @pytest.fixture + def related_part_fixture_(self, request, rels_, reltype): + related_part_ = instance_mock(request, Part, name='related_part_') + rels_.part_with_reltype.return_value = related_part_ + pkg = OpcPackage() + pkg._rels = rels_ + return pkg, reltype, related_part_ + + # fixture components ----------------------------------- + + @pytest.fixture + def core_properties_(self, request): + return instance_mock(request, CoreProperties) + + @pytest.fixture + def core_properties_part_(self, request): + return instance_mock(request, CorePropertiesPart) + + @pytest.fixture + def _core_properties_part_prop_(self, request): + return property_mock(request, OpcPackage, '_core_properties_part') + @pytest.fixture def PackageReader_(self, request): return class_mock(request, 'docx.opc.package.PackageReader') @@ -171,23 +219,6 @@ def rel_attrs_(self, request): rId = 'rId99' return reltype, target_, rId - @pytest.fixture - def relate_to_part_fixture_(self, request, pkg, rels_, reltype): - rId = 'rId99' - rel_ = instance_mock(request, _Relationship, name='rel_', rId=rId) - rels_.get_or_add.return_value = rel_ - pkg._rels = rels_ - part_ = instance_mock(request, Part, name='part_') - return pkg, part_, reltype, rId - - @pytest.fixture - def related_part_fixture_(self, request, rels_, reltype): - related_part_ = instance_mock(request, Part, name='related_part_') - rels_.part_with_reltype.return_value = related_part_ - pkg = OpcPackage() - pkg._rels = rels_ - return pkg, reltype, related_part_ - @pytest.fixture def rels_(self, request): return instance_mock(request, Relationships) From 1d949827abe15f69c09a98c02daf5afe3ab5f21b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 17:27:34 -0800 Subject: [PATCH 241/809] rfctr: extract opc.rel module from opc.package --- docx/opc/package.py | 162 +--------------------------------- docx/opc/rel.py | 170 ++++++++++++++++++++++++++++++++++++ tests/opc/test_package.py | 165 +---------------------------------- tests/opc/test_rel.py | 177 ++++++++++++++++++++++++++++++++++++++ tests/opc/test_rels.py | 136 ++--------------------------- 5 files changed, 358 insertions(+), 452 deletions(-) create mode 100644 docx/opc/rel.py create mode 100644 tests/opc/test_rel.py diff --git a/docx/opc/package.py b/docx/opc/package.py index bcfeb6a15..00ff30d57 100644 --- a/docx/opc/package.py +++ b/docx/opc/package.py @@ -9,11 +9,12 @@ from .compat import cls_method_fn from .constants import RELATIONSHIP_TYPE as RT -from .oxml import CT_Relationships, serialize_part_xml +from .oxml import serialize_part_xml from ..oxml import parse_xml from .packuri import PACKAGE_URI, PackURI from .pkgreader import PackageReader from .pkgwriter import PackageWriter +from .rel import Relationships from .shared import lazyproperty @@ -386,126 +387,6 @@ def _part_cls_for(cls, content_type): return cls.default_part_type -class Relationships(dict): - """ - Collection object for |_Relationship| instances, having list semantics. - """ - def __init__(self, baseURI): - super(Relationships, self).__init__() - self._baseURI = baseURI - self._target_parts_by_rId = {} - - def add_relationship(self, reltype, target, rId, is_external=False): - """ - Return a newly added |_Relationship| instance. - """ - rel = _Relationship(rId, reltype, target, self._baseURI, is_external) - self[rId] = rel - if not is_external: - self._target_parts_by_rId[rId] = target - return rel - - def get_or_add(self, reltype, target_part): - """ - Return relationship of *reltype* to *target_part*, newly added if not - already present in collection. - """ - rel = self._get_matching(reltype, target_part) - if rel is None: - rId = self._next_rId - rel = self.add_relationship(reltype, target_part, rId) - return rel - - def get_or_add_ext_rel(self, reltype, target_ref): - """ - Return rId of external relationship of *reltype* to *target_ref*, - newly added if not already present in collection. - """ - rel = self._get_matching(reltype, target_ref, is_external=True) - if rel is None: - rId = self._next_rId - rel = self.add_relationship( - reltype, target_ref, rId, is_external=True - ) - return rel.rId - - def part_with_reltype(self, reltype): - """ - Return target part of rel with matching *reltype*, raising |KeyError| - if not found and |ValueError| if more than one matching relationship - is found. - """ - rel = self._get_rel_of_type(reltype) - return rel.target_part - - @property - def related_parts(self): - """ - dict mapping rIds to target parts for all the internal relationships - in the collection. - """ - return self._target_parts_by_rId - - @property - def xml(self): - """ - Serialize this relationship collection into XML suitable for storage - as a .rels file in an OPC package. - """ - rels_elm = CT_Relationships.new() - for rel in self.values(): - rels_elm.add_rel( - rel.rId, rel.reltype, rel.target_ref, rel.is_external - ) - return rels_elm.xml - - def _get_matching(self, reltype, target, is_external=False): - """ - Return relationship of matching *reltype*, *target*, and - *is_external* from collection, or None if not found. - """ - def matches(rel, reltype, target, is_external): - if rel.reltype != reltype: - return False - if rel.is_external != is_external: - return False - rel_target = rel.target_ref if rel.is_external else rel.target_part - if rel_target != target: - return False - return True - - for rel in self.values(): - if matches(rel, reltype, target, is_external): - return rel - return None - - def _get_rel_of_type(self, reltype): - """ - Return single relationship of type *reltype* from the collection. - Raises |KeyError| if no matching relationship is found. Raises - |ValueError| if more than one matching relationship is found. - """ - matching = [rel for rel in self.values() if rel.reltype == reltype] - if len(matching) == 0: - tmpl = "no relationship of type '%s' in collection" - raise KeyError(tmpl % reltype) - if len(matching) > 1: - tmpl = "multiple relationships of type '%s' in collection" - raise ValueError(tmpl % reltype) - return matching[0] - - @property - 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): - rId_candidate = 'rId%d' % n # like 'rId19' - if rId_candidate not in self: - return rId_candidate - - class Unmarshaller(object): """ Hosts static methods for unmarshalling a package from a |PackageReader| @@ -552,42 +433,3 @@ def _unmarshal_relationships(pkg_reader, package, parts): target = (srel.target_ref if srel.is_external else parts[srel.target_partname]) source.load_rel(srel.reltype, target, srel.rId, srel.is_external) - - -class _Relationship(object): - """ - Value object for relationship to part. - """ - def __init__(self, rId, reltype, target, baseURI, external=False): - super(_Relationship, self).__init__() - self._rId = rId - self._reltype = reltype - self._target = target - self._baseURI = baseURI - self._is_external = bool(external) - - @property - def is_external(self): - return self._is_external - - @property - def reltype(self): - return self._reltype - - @property - def rId(self): - return self._rId - - @property - def target_part(self): - if self._is_external: - raise ValueError("target_part property on _Relationship is undef" - "ined when target mode is External") - return self._target - - @property - def target_ref(self): - if self._is_external: - return self._target - else: - return self._target.partname.relative_ref(self._baseURI) diff --git a/docx/opc/rel.py b/docx/opc/rel.py new file mode 100644 index 000000000..7dba2af8e --- /dev/null +++ b/docx/opc/rel.py @@ -0,0 +1,170 @@ +# encoding: utf-8 + +""" +Relationship-related objects. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from .oxml import CT_Relationships + + +class Relationships(dict): + """ + Collection object for |_Relationship| instances, having list semantics. + """ + def __init__(self, baseURI): + super(Relationships, self).__init__() + self._baseURI = baseURI + self._target_parts_by_rId = {} + + def add_relationship(self, reltype, target, rId, is_external=False): + """ + Return a newly added |_Relationship| instance. + """ + rel = _Relationship(rId, reltype, target, self._baseURI, is_external) + self[rId] = rel + if not is_external: + self._target_parts_by_rId[rId] = target + return rel + + def get_or_add(self, reltype, target_part): + """ + Return relationship of *reltype* to *target_part*, newly added if not + already present in collection. + """ + rel = self._get_matching(reltype, target_part) + if rel is None: + rId = self._next_rId + rel = self.add_relationship(reltype, target_part, rId) + return rel + + def get_or_add_ext_rel(self, reltype, target_ref): + """ + Return rId of external relationship of *reltype* to *target_ref*, + newly added if not already present in collection. + """ + rel = self._get_matching(reltype, target_ref, is_external=True) + if rel is None: + rId = self._next_rId + rel = self.add_relationship( + reltype, target_ref, rId, is_external=True + ) + return rel.rId + + def part_with_reltype(self, reltype): + """ + Return target part of rel with matching *reltype*, raising |KeyError| + if not found and |ValueError| if more than one matching relationship + is found. + """ + rel = self._get_rel_of_type(reltype) + return rel.target_part + + @property + def related_parts(self): + """ + dict mapping rIds to target parts for all the internal relationships + in the collection. + """ + return self._target_parts_by_rId + + @property + def xml(self): + """ + Serialize this relationship collection into XML suitable for storage + as a .rels file in an OPC package. + """ + rels_elm = CT_Relationships.new() + for rel in self.values(): + rels_elm.add_rel( + rel.rId, rel.reltype, rel.target_ref, rel.is_external + ) + return rels_elm.xml + + def _get_matching(self, reltype, target, is_external=False): + """ + Return relationship of matching *reltype*, *target*, and + *is_external* from collection, or None if not found. + """ + def matches(rel, reltype, target, is_external): + if rel.reltype != reltype: + return False + if rel.is_external != is_external: + return False + rel_target = rel.target_ref if rel.is_external else rel.target_part + if rel_target != target: + return False + return True + + for rel in self.values(): + if matches(rel, reltype, target, is_external): + return rel + return None + + def _get_rel_of_type(self, reltype): + """ + Return single relationship of type *reltype* from the collection. + Raises |KeyError| if no matching relationship is found. Raises + |ValueError| if more than one matching relationship is found. + """ + matching = [rel for rel in self.values() if rel.reltype == reltype] + if len(matching) == 0: + tmpl = "no relationship of type '%s' in collection" + raise KeyError(tmpl % reltype) + if len(matching) > 1: + tmpl = "multiple relationships of type '%s' in collection" + raise ValueError(tmpl % reltype) + return matching[0] + + @property + 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): + rId_candidate = 'rId%d' % n # like 'rId19' + if rId_candidate not in self: + return rId_candidate + + +class _Relationship(object): + """ + Value object for relationship to part. + """ + def __init__(self, rId, reltype, target, baseURI, external=False): + super(_Relationship, self).__init__() + self._rId = rId + self._reltype = reltype + self._target = target + self._baseURI = baseURI + self._is_external = bool(external) + + @property + def is_external(self): + return self._is_external + + @property + def reltype(self): + return self._reltype + + @property + def rId(self): + return self._rId + + @property + def target_part(self): + if self._is_external: + raise ValueError("target_part property on _Relationship is undef" + "ined when target mode is External") + return self._target + + @property + def target_ref(self): + if self._is_external: + return self._target + else: + return self._target.partname.relative_ref(self._baseURI) diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index 71d181e58..610c3244d 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -9,14 +9,13 @@ import pytest from docx.opc.coreprops import CoreProperties -from docx.opc.oxml import CT_Relationships from docx.opc.packuri import PACKAGE_URI, PackURI from docx.opc.package import ( - OpcPackage, Part, PartFactory, _Relationship, Relationships, - Unmarshaller, XmlPart + OpcPackage, Part, PartFactory, Unmarshaller, XmlPart ) from docx.opc.parts.coreprops import CorePropertiesPart from docx.opc.pkgreader import PackageReader +from docx.opc.rel import _Relationship, Relationships from docx.oxml.xmlchemy import BaseOxmlElement from ..unitutil.cxml import element @@ -644,166 +643,6 @@ def reltype_2_(self, request): return instance_mock(request, str) -class Describe_Relationship(object): - - def it_remembers_construction_values(self): - # test data -------------------- - rId = 'rId9' - reltype = 'reltype' - target = Mock(name='target_part') - external = False - # exercise --------------------- - rel = _Relationship(rId, reltype, target, None, external) - # verify ----------------------- - assert rel.rId == rId - assert rel.reltype == reltype - assert rel.target_part == target - assert rel.is_external == external - - def it_should_raise_on_target_part_access_on_external_rel(self): - rel = _Relationship(None, None, None, None, external=True) - with pytest.raises(ValueError): - rel.target_part - - def it_should_have_target_ref_for_external_rel(self): - rel = _Relationship(None, None, 'target', None, external=True) - assert rel.target_ref == 'target' - - def it_should_have_relative_ref_for_internal_rel(self): - """ - Internal relationships (TargetMode == 'Internal' in the XML) should - have a relative ref, e.g. '../slideLayouts/slideLayout1.xml', for - the target_ref attribute. - """ - part = Mock(name='part', partname=PackURI('/ppt/media/image1.png')) - baseURI = '/ppt/slides' - rel = _Relationship(None, None, part, baseURI) # external=False - assert rel.target_ref == '../media/image1.png' - - -class DescribeRelationships(object): - - def it_has_a_len(self): - rels = Relationships(None) - assert len(rels) == 0 - - def it_has_dict_style_lookup_of_rel_by_rId(self): - rel = Mock(name='rel', rId='foobar') - rels = Relationships(None) - rels['foobar'] = rel - assert rels['foobar'] == rel - - def it_should_raise_on_failed_lookup_by_rId(self): - rels = Relationships(None) - with pytest.raises(KeyError): - rels['barfoo'] - - def it_can_add_a_relationship(self, _Relationship_): - baseURI, rId, reltype, target, external = ( - 'baseURI', 'rId9', 'reltype', 'target', False - ) - rels = Relationships(baseURI) - rel = rels.add_relationship(reltype, target, rId, external) - _Relationship_.assert_called_once_with( - rId, reltype, target, baseURI, external - ) - assert rels[rId] == rel - assert rel == _Relationship_.return_value - - def it_can_add_an_external_relationship(self, add_ext_rel_fixture_): - rels, reltype, url = add_ext_rel_fixture_ - rId = rels.get_or_add_ext_rel(reltype, url) - rel = rels[rId] - assert rel.is_external - assert rel.target_ref == url - assert rel.reltype == reltype - - def it_should_return_an_existing_one_if_it_matches( - self, add_matching_ext_rel_fixture_): - rels, reltype, url, rId = add_matching_ext_rel_fixture_ - _rId = rels.get_or_add_ext_rel(reltype, url) - assert _rId == rId - assert len(rels) == 1 - - def it_can_compose_rels_xml(self, rels, rels_elm): - # exercise --------------------- - rels.xml - # verify ----------------------- - rels_elm.assert_has_calls( - [ - call.add_rel( - 'rId1', 'http://rt-hyperlink', 'http://some/link', True - ), - call.add_rel( - 'rId2', 'http://rt-image', '../media/image1.png', False - ), - call.xml() - ], - any_order=True - ) - - # fixtures --------------------------------------------- - - @pytest.fixture - def add_ext_rel_fixture_(self, reltype, url): - rels = Relationships(None) - return rels, reltype, url - - @pytest.fixture - def add_matching_ext_rel_fixture_(self, request, reltype, url): - rId = 'rId369' - rels = Relationships(None) - rels.add_relationship(reltype, url, rId, is_external=True) - return rels, reltype, url, rId - - @pytest.fixture - def _Relationship_(self, request): - return class_mock(request, 'docx.opc.package._Relationship') - - @pytest.fixture - def rels(self): - """ - Populated Relationships instance that will exercise the rels.xml - property. - """ - rels = Relationships('/baseURI') - rels.add_relationship( - reltype='http://rt-hyperlink', target='http://some/link', - rId='rId1', is_external=True - ) - part = Mock(name='part') - part.partname.relative_ref.return_value = '../media/image1.png' - rels.add_relationship(reltype='http://rt-image', target=part, - rId='rId2') - return rels - - @pytest.fixture - def rels_elm(self, request): - """ - Return a rels_elm mock that will be returned from - CT_Relationships.new() - """ - # create rels_elm mock with a .xml property - rels_elm = Mock(name='rels_elm') - xml = PropertyMock(name='xml') - type(rels_elm).xml = xml - rels_elm.attach_mock(xml, 'xml') - rels_elm.reset_mock() # to clear attach_mock call - # patch CT_Relationships to return that rels_elm - patch_ = patch.object(CT_Relationships, 'new', return_value=rels_elm) - patch_.start() - request.addfinalizer(patch_.stop) - return rels_elm - - @pytest.fixture - def reltype(self): - return 'http://rel/type' - - @pytest.fixture - def url(self): - return 'https://github.com/scanny/python-docx' - - class DescribeUnmarshaller(object): def it_can_unmarshal_from_a_pkg_reader( diff --git a/tests/opc/test_rel.py b/tests/opc/test_rel.py new file mode 100644 index 000000000..d0710fc7c --- /dev/null +++ b/tests/opc/test_rel.py @@ -0,0 +1,177 @@ +# encoding: utf-8 + +""" +Unit test suite for the docx.opc.rel module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.opc.oxml import CT_Relationships +from docx.opc.packuri import PackURI +from docx.opc.rel import _Relationship, Relationships + +from ..unitutil.mock import call, class_mock, Mock, patch, PropertyMock + + +class Describe_Relationship(object): + + def it_remembers_construction_values(self): + # test data -------------------- + rId = 'rId9' + reltype = 'reltype' + target = Mock(name='target_part') + external = False + # exercise --------------------- + rel = _Relationship(rId, reltype, target, None, external) + # verify ----------------------- + assert rel.rId == rId + assert rel.reltype == reltype + assert rel.target_part == target + assert rel.is_external == external + + def it_should_raise_on_target_part_access_on_external_rel(self): + rel = _Relationship(None, None, None, None, external=True) + with pytest.raises(ValueError): + rel.target_part + + def it_should_have_target_ref_for_external_rel(self): + rel = _Relationship(None, None, 'target', None, external=True) + assert rel.target_ref == 'target' + + def it_should_have_relative_ref_for_internal_rel(self): + """ + Internal relationships (TargetMode == 'Internal' in the XML) should + have a relative ref, e.g. '../slideLayouts/slideLayout1.xml', for + the target_ref attribute. + """ + part = Mock(name='part', partname=PackURI('/ppt/media/image1.png')) + baseURI = '/ppt/slides' + rel = _Relationship(None, None, part, baseURI) # external=False + assert rel.target_ref == '../media/image1.png' + + +class DescribeRelationships(object): + + def it_has_a_len(self): + rels = Relationships(None) + assert len(rels) == 0 + + def it_has_dict_style_lookup_of_rel_by_rId(self): + rel = Mock(name='rel', rId='foobar') + rels = Relationships(None) + rels['foobar'] = rel + assert rels['foobar'] == rel + + def it_should_raise_on_failed_lookup_by_rId(self): + rels = Relationships(None) + with pytest.raises(KeyError): + rels['barfoo'] + + def it_can_add_a_relationship(self, _Relationship_): + baseURI, rId, reltype, target, external = ( + 'baseURI', 'rId9', 'reltype', 'target', False + ) + rels = Relationships(baseURI) + rel = rels.add_relationship(reltype, target, rId, external) + _Relationship_.assert_called_once_with( + rId, reltype, target, baseURI, external + ) + assert rels[rId] == rel + assert rel == _Relationship_.return_value + + def it_can_add_an_external_relationship(self, add_ext_rel_fixture_): + rels, reltype, url = add_ext_rel_fixture_ + rId = rels.get_or_add_ext_rel(reltype, url) + rel = rels[rId] + assert rel.is_external + assert rel.target_ref == url + assert rel.reltype == reltype + + def it_should_return_an_existing_one_if_it_matches( + self, add_matching_ext_rel_fixture_): + rels, reltype, url, rId = add_matching_ext_rel_fixture_ + _rId = rels.get_or_add_ext_rel(reltype, url) + assert _rId == rId + assert len(rels) == 1 + + def it_can_compose_rels_xml(self, rels, rels_elm): + # exercise --------------------- + rels.xml + # verify ----------------------- + rels_elm.assert_has_calls( + [ + call.add_rel( + 'rId1', 'http://rt-hyperlink', 'http://some/link', True + ), + call.add_rel( + 'rId2', 'http://rt-image', '../media/image1.png', False + ), + call.xml() + ], + any_order=True + ) + + # fixtures --------------------------------------------- + + @pytest.fixture + def add_ext_rel_fixture_(self, reltype, url): + rels = Relationships(None) + return rels, reltype, url + + @pytest.fixture + def add_matching_ext_rel_fixture_(self, request, reltype, url): + rId = 'rId369' + rels = Relationships(None) + rels.add_relationship(reltype, url, rId, is_external=True) + return rels, reltype, url, rId + + @pytest.fixture + def _Relationship_(self, request): + return class_mock(request, 'docx.opc.rel._Relationship') + + @pytest.fixture + def rels(self): + """ + Populated Relationships instance that will exercise the rels.xml + property. + """ + rels = Relationships('/baseURI') + rels.add_relationship( + reltype='http://rt-hyperlink', target='http://some/link', + rId='rId1', is_external=True + ) + part = Mock(name='part') + part.partname.relative_ref.return_value = '../media/image1.png' + rels.add_relationship(reltype='http://rt-image', target=part, + rId='rId2') + return rels + + @pytest.fixture + def rels_elm(self, request): + """ + Return a rels_elm mock that will be returned from + CT_Relationships.new() + """ + # create rels_elm mock with a .xml property + rels_elm = Mock(name='rels_elm') + xml = PropertyMock(name='xml') + type(rels_elm).xml = xml + rels_elm.attach_mock(xml, 'xml') + rels_elm.reset_mock() # to clear attach_mock call + # patch CT_Relationships to return that rels_elm + patch_ = patch.object(CT_Relationships, 'new', return_value=rels_elm) + patch_.start() + request.addfinalizer(patch_.stop) + return rels_elm + + @pytest.fixture + def reltype(self): + return 'http://rel/type' + + @pytest.fixture + def url(self): + return 'https://github.com/scanny/python-docx' diff --git a/tests/opc/test_rels.py b/tests/opc/test_rels.py index 61036410c..d46566d80 100644 --- a/tests/opc/test_rels.py +++ b/tests/opc/test_rels.py @@ -9,77 +9,14 @@ import pytest from docx.opc.constants import RELATIONSHIP_TYPE as RT -from docx.opc.oxml import CT_Relationships -from docx.opc.package import Part, _Relationship, Relationships -from docx.opc.packuri import PackURI +from docx.opc.package import Part +from docx.opc.rel import _Relationship, Relationships -from ..unitutil.mock import ( - call, class_mock, instance_mock, loose_mock, Mock, patch, PropertyMock -) - - -class Describe_Relationship(object): - - def it_remembers_construction_values(self): - # test data -------------------- - rId = 'rId9' - reltype = 'reltype' - target = Mock(name='target_part') - external = False - # exercise --------------------- - rel = _Relationship(rId, reltype, target, None, external) - # verify ----------------------- - assert rel.rId == rId - assert rel.reltype == reltype - assert rel.target_part == target - assert rel.is_external == external - - def it_should_raise_on_target_part_access_on_external_rel(self): - rel = _Relationship(None, None, None, None, external=True) - with pytest.raises(ValueError): - rel.target_part - - def it_should_have_target_ref_for_external_rel(self): - rel = _Relationship(None, None, 'target', None, external=True) - assert rel.target_ref == 'target' - - def it_should_have_relative_ref_for_internal_rel(self): - """ - Internal relationships (TargetMode == 'Internal' in the XML) should - have a relative ref, e.g. '../slideLayouts/slideLayout1.xml', for - the target_ref attribute. - """ - part = Mock(name='part', partname=PackURI('/ppt/media/image1.png')) - baseURI = '/ppt/slides' - rel = _Relationship(None, None, part, baseURI) # external=False - assert rel.target_ref == '../media/image1.png' +from ..unitutil.mock import class_mock, instance_mock, loose_mock class DescribeRelationships(object): - def it_also_has_dict_style_get_rel_by_rId(self, rels_with_known_rel): - rels, rId, known_rel = rels_with_known_rel - assert rels[rId] == known_rel - - def it_should_raise_on_failed_lookup_by_rId(self, rels): - with pytest.raises(KeyError): - rels['rId666'] - - def it_has_a_len(self, rels): - assert len(rels) == 0 - - def it_can_add_a_relationship(self, _Relationship_): - baseURI, rId, reltype, target, is_external = ( - 'baseURI', 'rId9', 'reltype', 'target', False - ) - rels = Relationships(baseURI) - rel = rels.add_relationship(reltype, target, rId, is_external) - _Relationship_.assert_called_once_with( - rId, reltype, target, baseURI, is_external - ) - assert rels[rId] == rel - assert rel == _Relationship_.return_value - def it_can_add_a_relationship_if_not_found( self, rels_with_matching_rel_, rels_with_missing_rel_): @@ -109,21 +46,6 @@ def it_raises_KeyError_on_part_with_rId_not_found(self, rels): with pytest.raises(KeyError): rels.related_parts['rId666'] - def it_can_compose_rels_xml(self, rels_with_known_rels, rels_elm): - rels_with_known_rels.xml - rels_elm.assert_has_calls( - [ - call.add_rel( - 'rId1', 'http://rt-hyperlink', 'http://some/link', True - ), - call.add_rel( - 'rId2', 'http://rt-image', '../media/image1.png', False - ), - call.xml() - ], - any_order=True - ) - # def it_raises_on_add_rel_with_duplicate_rId(self, rels, rel): # with pytest.raises(ValueError): # rels.add_rel(rel) @@ -131,57 +53,17 @@ def it_can_compose_rels_xml(self, rels_with_known_rels, rels_elm): # fixtures --------------------------------------------- @pytest.fixture - def _Relationship_(self, request): - return class_mock(request, 'docx.opc.package._Relationship') + def _baseURI(self): + return '/baseURI' @pytest.fixture - def rel(self, _rId, _reltype, _target_part, _baseURI): - return _Relationship(_rId, _reltype, _target_part, _baseURI) + def _Relationship_(self, request): + return class_mock(request, 'docx.opc.rel._Relationship') @pytest.fixture def rels(self, _baseURI): return Relationships(_baseURI) - @pytest.fixture - def rels_elm(self, request): - """ - Return a rels_elm mock that will be returned from - CT_Relationships.new() - """ - # create rels_elm mock with a .xml property - rels_elm = Mock(name='rels_elm') - xml = PropertyMock(name='xml') - type(rels_elm).xml = xml - rels_elm.attach_mock(xml, 'xml') - rels_elm.reset_mock() # to clear attach_mock call - # patch CT_Relationships to return that rels_elm - patch_ = patch.object(CT_Relationships, 'new', return_value=rels_elm) - patch_.start() - request.addfinalizer(patch_.stop) - return rels_elm - - @pytest.fixture - def rels_with_known_rel(self, rels, _rId, rel): - rels[_rId] = rel - return rels, _rId, rel - - @pytest.fixture - def rels_with_known_rels(self): - """ - Populated Relationships instance that will exercise the rels.xml - property. - """ - rels = Relationships('/baseURI') - rels.add_relationship( - reltype='http://rt-hyperlink', target='http://some/link', - rId='rId1', is_external=True - ) - part = Mock(name='part') - part.partname.relative_ref.return_value = '../media/image1.png' - rels.add_relationship(reltype='http://rt-image', target=part, - rId='rId2') - return rels - @pytest.fixture def rels_with_known_target_part(self, rels, _rel_with_known_target_part): rel, rId, target_part = _rel_with_known_target_part @@ -239,10 +121,6 @@ def rels_with_target_known_by_reltype( rels[1] = rel return rels, reltype, target_part - @pytest.fixture - def _baseURI(self): - return '/baseURI' - @pytest.fixture def _rel_with_known_target_part( self, _rId, _reltype, _target_part, _baseURI): From 734ce6fe192446180119b7edb5be8045dbb5fe5a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 18:00:50 -0800 Subject: [PATCH 242/809] opc: consolidate tests for docx.opc.rel Somehow tests for Relationships became fragmented between test_package and test_rels. Move additional tests from test_rels into new test_rel module. --- tests/opc/test_rel.py | 141 ++++++++++++++++++++++++++++++++++----- tests/opc/test_rels.py | 146 ----------------------------------------- 2 files changed, 124 insertions(+), 163 deletions(-) delete mode 100644 tests/opc/test_rels.py diff --git a/tests/opc/test_rel.py b/tests/opc/test_rel.py index d0710fc7c..db39aa145 100644 --- a/tests/opc/test_rel.py +++ b/tests/opc/test_rel.py @@ -11,10 +11,13 @@ import pytest from docx.opc.oxml import CT_Relationships +from docx.opc.package import Part from docx.opc.packuri import PackURI from docx.opc.rel import _Relationship, Relationships -from ..unitutil.mock import call, class_mock, Mock, patch, PropertyMock +from ..unitutil.mock import ( + call, class_mock, instance_mock, Mock, patch, PropertyMock +) class Describe_Relationship(object): @@ -56,21 +59,6 @@ def it_should_have_relative_ref_for_internal_rel(self): class DescribeRelationships(object): - def it_has_a_len(self): - rels = Relationships(None) - assert len(rels) == 0 - - def it_has_dict_style_lookup_of_rel_by_rId(self): - rel = Mock(name='rel', rId='foobar') - rels = Relationships(None) - rels['foobar'] = rel - assert rels['foobar'] == rel - - def it_should_raise_on_failed_lookup_by_rId(self): - rels = Relationships(None) - with pytest.raises(KeyError): - rels['barfoo'] - def it_can_add_a_relationship(self, _Relationship_): baseURI, rId, reltype, target, external = ( 'baseURI', 'rId9', 'reltype', 'target', False @@ -91,13 +79,43 @@ def it_can_add_an_external_relationship(self, add_ext_rel_fixture_): assert rel.target_ref == url assert rel.reltype == reltype - def it_should_return_an_existing_one_if_it_matches( + def it_can_find_a_relationship_by_rId(self): + rel = Mock(name='rel', rId='foobar') + rels = Relationships(None) + rels['foobar'] = rel + assert rels['foobar'] == rel + + def it_can_find_or_add_a_relationship( + self, rels_with_matching_rel_, rels_with_missing_rel_): + + rels, reltype, part, matching_rel = rels_with_matching_rel_ + assert rels.get_or_add(reltype, part) == matching_rel + + rels, reltype, part, new_rel = rels_with_missing_rel_ + assert rels.get_or_add(reltype, part) == new_rel + + def it_can_find_or_add_an_external_relationship( self, add_matching_ext_rel_fixture_): rels, reltype, url, rId = add_matching_ext_rel_fixture_ _rId = rels.get_or_add_ext_rel(reltype, url) assert _rId == rId assert len(rels) == 1 + def it_can_find_a_related_part_by_rId(self, rels_with_known_target_part): + rels, rId, known_target_part = rels_with_known_target_part + part = rels.related_parts[rId] + assert part is known_target_part + + def it_raises_on_related_part_not_found(self, rels): + with pytest.raises(KeyError): + rels.related_parts['rId666'] + + def it_can_find_a_related_part_by_reltype( + self, rels_with_target_known_by_reltype): + rels, reltype, known_target_part = rels_with_target_known_by_reltype + part = rels.part_with_reltype(reltype) + assert part is known_target_part + def it_can_compose_rels_xml(self, rels, rels_elm): # exercise --------------------- rels.xml @@ -115,6 +133,11 @@ def it_can_compose_rels_xml(self, rels, rels_elm): any_order=True ) + def it_knows_the_next_available_rId_to_help(self, rels_with_rId_gap): + rels, expected_next_rId = rels_with_rId_gap + next_rId = rels._next_rId + assert next_rId == expected_next_rId + # fixtures --------------------------------------------- @pytest.fixture @@ -129,10 +152,22 @@ def add_matching_ext_rel_fixture_(self, request, reltype, url): rels.add_relationship(reltype, url, rId, is_external=True) return rels, reltype, url, rId + # fixture components ----------------------------------- + + @pytest.fixture + def _baseURI(self): + return '/baseURI' + @pytest.fixture def _Relationship_(self, request): return class_mock(request, 'docx.opc.rel._Relationship') + @pytest.fixture + def _rel_with_target_known_by_reltype( + self, _rId, reltype, _target_part, _baseURI): + rel = _Relationship(_rId, reltype, _target_part, _baseURI) + return rel, reltype, _target_part + @pytest.fixture def rels(self): """ @@ -168,10 +203,82 @@ def rels_elm(self, request): request.addfinalizer(patch_.stop) return rels_elm + @pytest.fixture + def _rel_with_known_target_part( + self, _rId, reltype, _target_part, _baseURI): + rel = _Relationship(_rId, reltype, _target_part, _baseURI) + return rel, _rId, _target_part + + @pytest.fixture + def rels_with_known_target_part(self, rels, _rel_with_known_target_part): + rel, rId, target_part = _rel_with_known_target_part + rels.add_relationship(None, target_part, rId) + return rels, rId, target_part + + @pytest.fixture + def rels_with_matching_rel_(self, request, rels): + matching_reltype_ = instance_mock( + request, str, name='matching_reltype_' + ) + matching_part_ = instance_mock( + request, Part, name='matching_part_' + ) + matching_rel_ = instance_mock( + request, _Relationship, name='matching_rel_', + reltype=matching_reltype_, target_part=matching_part_, + is_external=False + ) + rels[1] = matching_rel_ + return rels, matching_reltype_, matching_part_, matching_rel_ + + @pytest.fixture + def rels_with_missing_rel_(self, request, rels, _Relationship_): + missing_reltype_ = instance_mock( + request, str, name='missing_reltype_' + ) + missing_part_ = instance_mock( + request, Part, name='missing_part_' + ) + new_rel_ = instance_mock( + request, _Relationship, name='new_rel_', + reltype=missing_reltype_, target_part=missing_part_, + is_external=False + ) + _Relationship_.return_value = new_rel_ + return rels, missing_reltype_, missing_part_, new_rel_ + + @pytest.fixture + def rels_with_rId_gap(self, request): + rels = Relationships(None) + rel_with_rId1 = instance_mock( + request, _Relationship, name='rel_with_rId1', rId='rId1' + ) + rel_with_rId3 = instance_mock( + request, _Relationship, name='rel_with_rId3', rId='rId3' + ) + rels['rId1'] = rel_with_rId1 + rels['rId3'] = rel_with_rId3 + return rels, 'rId2' + + @pytest.fixture + def rels_with_target_known_by_reltype( + self, rels, _rel_with_target_known_by_reltype): + rel, reltype, target_part = _rel_with_target_known_by_reltype + rels[1] = rel + return rels, reltype, target_part + @pytest.fixture def reltype(self): return 'http://rel/type' + @pytest.fixture + def _rId(self): + return 'rId6' + + @pytest.fixture + def _target_part(self, request): + return instance_mock(request, Part) + @pytest.fixture def url(self): return 'https://github.com/scanny/python-docx' diff --git a/tests/opc/test_rels.py b/tests/opc/test_rels.py deleted file mode 100644 index d46566d80..000000000 --- a/tests/opc/test_rels.py +++ /dev/null @@ -1,146 +0,0 @@ -# encoding: utf-8 - -""" -Test suite for docx.opc relationships -""" - -from __future__ import absolute_import - -import pytest - -from docx.opc.constants import RELATIONSHIP_TYPE as RT -from docx.opc.package import Part -from docx.opc.rel import _Relationship, Relationships - -from ..unitutil.mock import class_mock, instance_mock, loose_mock - - -class DescribeRelationships(object): - - def it_can_add_a_relationship_if_not_found( - self, rels_with_matching_rel_, rels_with_missing_rel_): - - rels, reltype, part, matching_rel = rels_with_matching_rel_ - assert rels.get_or_add(reltype, part) == matching_rel - - rels, reltype, part, new_rel = rels_with_missing_rel_ - assert rels.get_or_add(reltype, part) == new_rel - - def it_knows_the_next_available_rId(self, rels_with_rId_gap): - rels, expected_next_rId = rels_with_rId_gap - next_rId = rels._next_rId - assert next_rId == expected_next_rId - - def it_can_find_a_related_part_by_reltype( - self, rels_with_target_known_by_reltype): - rels, reltype, known_target_part = rels_with_target_known_by_reltype - part = rels.part_with_reltype(reltype) - assert part is known_target_part - - def it_can_find_a_related_part_by_rId(self, rels_with_known_target_part): - rels, rId, known_target_part = rels_with_known_target_part - part = rels.related_parts[rId] - assert part is known_target_part - - def it_raises_KeyError_on_part_with_rId_not_found(self, rels): - with pytest.raises(KeyError): - rels.related_parts['rId666'] - - # def it_raises_on_add_rel_with_duplicate_rId(self, rels, rel): - # with pytest.raises(ValueError): - # rels.add_rel(rel) - - # fixtures --------------------------------------------- - - @pytest.fixture - def _baseURI(self): - return '/baseURI' - - @pytest.fixture - def _Relationship_(self, request): - return class_mock(request, 'docx.opc.rel._Relationship') - - @pytest.fixture - def rels(self, _baseURI): - return Relationships(_baseURI) - - @pytest.fixture - def rels_with_known_target_part(self, rels, _rel_with_known_target_part): - rel, rId, target_part = _rel_with_known_target_part - rels.add_relationship(None, target_part, rId) - return rels, rId, target_part - - @pytest.fixture - def rels_with_matching_rel_(self, request, rels): - matching_reltype_ = instance_mock( - request, str, name='matching_reltype_' - ) - matching_part_ = instance_mock( - request, Part, name='matching_part_' - ) - matching_rel_ = instance_mock( - request, _Relationship, name='matching_rel_', - reltype=matching_reltype_, target_part=matching_part_, - is_external=False - ) - rels[1] = matching_rel_ - return rels, matching_reltype_, matching_part_, matching_rel_ - - @pytest.fixture - def rels_with_missing_rel_(self, request, rels, _Relationship_): - missing_reltype_ = instance_mock( - request, str, name='missing_reltype_' - ) - missing_part_ = instance_mock( - request, Part, name='missing_part_' - ) - new_rel_ = instance_mock( - request, _Relationship, name='new_rel_', - reltype=missing_reltype_, target_part=missing_part_, - is_external=False - ) - _Relationship_.return_value = new_rel_ - return rels, missing_reltype_, missing_part_, new_rel_ - - @pytest.fixture - def rels_with_rId_gap(self, request, rels): - rel_with_rId1 = instance_mock( - request, _Relationship, name='rel_with_rId1', rId='rId1' - ) - rel_with_rId3 = instance_mock( - request, _Relationship, name='rel_with_rId3', rId='rId3' - ) - rels['rId1'] = rel_with_rId1 - rels['rId3'] = rel_with_rId3 - return rels, 'rId2' - - @pytest.fixture - def rels_with_target_known_by_reltype( - self, rels, _rel_with_target_known_by_reltype): - rel, reltype, target_part = _rel_with_target_known_by_reltype - rels[1] = rel - return rels, reltype, target_part - - @pytest.fixture - def _rel_with_known_target_part( - self, _rId, _reltype, _target_part, _baseURI): - rel = _Relationship(_rId, _reltype, _target_part, _baseURI) - return rel, _rId, _target_part - - @pytest.fixture - def _rel_with_target_known_by_reltype( - self, _rId, _reltype, _target_part, _baseURI): - rel = _Relationship(_rId, _reltype, _target_part, _baseURI) - return rel, _reltype, _target_part - - @pytest.fixture - def _reltype(self): - return RT.SLIDE - - @pytest.fixture - def _rId(self): - return 'rId6' - - @pytest.fixture - def _target_part(self, request): - return loose_mock(request) From 5f820f8950bca6b9f5e54956a260734f32057da8 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 19:20:35 -0800 Subject: [PATCH 243/809] rfctr: extract opc.part module from opc.package --- docx/__init__.py | 2 +- docx/opc/package.py | 224 +--------------- docx/opc/part.py | 234 ++++++++++++++++ docx/opc/parts/coreprops.py | 2 +- docx/parts/document.py | 2 +- docx/parts/image.py | 2 +- docx/parts/numbering.py | 2 +- docx/parts/styles.py | 2 +- tests/opc/test_package.py | 509 +---------------------------------- tests/opc/test_part.py | 518 ++++++++++++++++++++++++++++++++++++ tests/opc/test_pkgwriter.py | 2 +- tests/opc/test_rel.py | 2 +- tests/opc/unitdata/rels.py | 2 +- tests/parts/test_image.py | 2 +- 14 files changed, 769 insertions(+), 736 deletions(-) create mode 100644 docx/opc/part.py create mode 100644 tests/opc/test_part.py diff --git a/docx/__init__.py b/docx/__init__.py index a738c0781..f0f3ca482 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -8,7 +8,7 @@ # register custom Part classes with opc package reader from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT -from docx.opc.package import PartFactory +from docx.opc.part import PartFactory from docx.parts.document import DocumentPart from docx.parts.image import ImagePart diff --git a/docx/opc/package.py b/docx/opc/package.py index 00ff30d57..024a8e54e 100644 --- a/docx/opc/package.py +++ b/docx/opc/package.py @@ -7,11 +7,9 @@ from __future__ import absolute_import, print_function, unicode_literals -from .compat import cls_method_fn from .constants import RELATIONSHIP_TYPE as RT -from .oxml import serialize_part_xml -from ..oxml import parse_xml -from .packuri import PACKAGE_URI, PackURI +from .packuri import PACKAGE_URI +from .part import PartFactory from .pkgreader import PackageReader from .pkgwriter import PackageWriter from .rel import Relationships @@ -169,224 +167,6 @@ def _core_properties_part(self): raise NotImplementedError -class Part(object): - """ - Base class for package parts. Provides common properties and methods, but - intended to be subclassed in client code to implement specific part - behaviors. - """ - def __init__(self, partname, content_type, blob=None, package=None): - super(Part, self).__init__() - self._partname = partname - self._content_type = content_type - self._blob = blob - self._package = package - - def after_unmarshal(self): - """ - Entry point for post-unmarshaling processing, for example to parse - the part XML. May be overridden by subclasses without forwarding call - to super. - """ - # don't place any code here, just catch call if not overridden by - # subclass - pass - - def before_marshal(self): - """ - Entry point for pre-serialization processing, for example to finalize - part naming if necessary. May be overridden by subclasses without - forwarding call to super. - """ - # don't place any code here, just catch call if not overridden by - # subclass - pass - - @property - def blob(self): - """ - Contents of this package part as a sequence of bytes. May be text or - binary. Intended to be overridden by subclasses. Default behavior is - to return load blob. - """ - return self._blob - - @property - def content_type(self): - """ - Content type of this part. - """ - return self._content_type - - def drop_rel(self, rId): - """ - Remove the relationship identified by *rId* if its reference count - is less than 2. Relationships with a reference count of 0 are - implicit relationships. - """ - if self._rel_ref_count(rId) < 2: - del self.rels[rId] - - @classmethod - def load(cls, partname, content_type, blob, package): - return cls(partname, content_type, blob, package) - - def load_rel(self, reltype, target, rId, is_external=False): - """ - Return newly added |_Relationship| instance of *reltype* between this - part and *target* with key *rId*. Target mode is set to - ``RTM.EXTERNAL`` if *is_external* is |True|. Intended for use during - load from a serialized package, where the rId is well-known. Other - methods exist for adding a new relationship to a part when - manipulating a part. - """ - return self.rels.add_relationship(reltype, target, rId, is_external) - - @property - def partname(self): - """ - |PackURI| instance holding partname of this part, e.g. - '/ppt/slides/slide1.xml' - """ - return self._partname - - @partname.setter - def partname(self, partname): - if not isinstance(partname, PackURI): - tmpl = "partname must be instance of PackURI, got '%s'" - raise TypeError(tmpl % type(partname).__name__) - self._partname = partname - - @property - def package(self): - """ - |OpcPackage| instance this part belongs to. - """ - return self._package - - def part_related_by(self, reltype): - """ - Return part to which this part has a relationship of *reltype*. - Raises |KeyError| if no such relationship is found and |ValueError| - if more than one such relationship is found. Provides ability to - resolve implicitly related part, such as Slide -> SlideLayout. - """ - return self.rels.part_with_reltype(reltype) - - def relate_to(self, target, reltype, is_external=False): - """ - Return rId key of relationship of *reltype* to *target*, from an - existing relationship if there is one, otherwise a newly created one. - """ - if is_external: - return self.rels.get_or_add_ext_rel(reltype, target) - else: - rel = self.rels.get_or_add(reltype, target) - return rel.rId - - @property - def related_parts(self): - """ - Dictionary mapping related parts by rId, so child objects can resolve - explicit relationships present in the part XML, e.g. sldIdLst to a - specific |Slide| instance. - """ - return self.rels.related_parts - - @lazyproperty - def rels(self): - """ - |Relationships| instance holding the relationships for this part. - """ - return Relationships(self._partname.baseURI) - - def target_ref(self, rId): - """ - Return URL contained in target ref of relationship identified by - *rId*. - """ - rel = self.rels[rId] - return rel.target_ref - - def _rel_ref_count(self, rId): - """ - Return the count of references in this part's XML to the relationship - identified by *rId*. - """ - rIds = self._element.xpath('//@r:id') - return len([_rId for _rId in rIds if _rId == rId]) - - -class XmlPart(Part): - """ - Base class for package parts containing an XML payload, which is most of - them. Provides additional methods to the |Part| base class that take care - of parsing and reserializing the XML payload and managing relationships - to other parts. - """ - def __init__(self, partname, content_type, element, package): - super(XmlPart, self).__init__( - partname, content_type, package=package - ) - self._element = element - - @property - def blob(self): - return serialize_part_xml(self._element) - - @classmethod - def load(cls, partname, content_type, blob, package): - element = parse_xml(blob) - return cls(partname, content_type, element, package) - - @property - def part(self): - """ - Part of the parent protocol, "children" of the document will not know - the part that contains them so must ask their parent object. That - chain of delegation ends here for child objects. - """ - return self - - -class PartFactory(object): - """ - Provides a way for client code to specify a subclass of |Part| to be - constructed by |Unmarshaller| based on its content type and/or a custom - callable. Setting ``PartFactory.part_class_selector`` to a callable - object will cause that object to be called with the parameters - ``content_type, reltype``, once for each part in the package. If the - callable returns an object, it is used as the class for that part. If it - returns |None|, part class selection falls back to the content type map - defined in ``PartFactory.part_type_for``. If no class is returned from - either of these, the class contained in ``PartFactory.default_part_type`` - is used to construct the part, which is by default ``opc.package.Part``. - """ - part_class_selector = None - part_type_for = {} - default_part_type = Part - - def __new__(cls, partname, content_type, reltype, blob, package): - PartClass = None - if cls.part_class_selector is not None: - part_class_selector = cls_method_fn(cls, 'part_class_selector') - PartClass = part_class_selector(content_type, reltype) - if PartClass is None: - PartClass = cls._part_cls_for(content_type) - return PartClass.load(partname, content_type, blob, package) - - @classmethod - def _part_cls_for(cls, content_type): - """ - Return the custom part class registered for *content_type*, or the - default part class if no custom class is registered for - *content_type*. - """ - if content_type in cls.part_type_for: - return cls.part_type_for[content_type] - return cls.default_part_type - - class Unmarshaller(object): """ Hosts static methods for unmarshalling a package from a |PackageReader| diff --git a/docx/opc/part.py b/docx/opc/part.py new file mode 100644 index 000000000..ed1362110 --- /dev/null +++ b/docx/opc/part.py @@ -0,0 +1,234 @@ +# encoding: utf-8 + +""" +Open Packaging Convention (OPC) objects related to package parts. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from .compat import cls_method_fn +from .oxml import serialize_part_xml +from ..oxml import parse_xml +from .packuri import PackURI +from .rel import Relationships +from .shared import lazyproperty + + +class Part(object): + """ + Base class for package parts. Provides common properties and methods, but + intended to be subclassed in client code to implement specific part + behaviors. + """ + def __init__(self, partname, content_type, blob=None, package=None): + super(Part, self).__init__() + self._partname = partname + self._content_type = content_type + self._blob = blob + self._package = package + + def after_unmarshal(self): + """ + Entry point for post-unmarshaling processing, for example to parse + the part XML. May be overridden by subclasses without forwarding call + to super. + """ + # don't place any code here, just catch call if not overridden by + # subclass + pass + + def before_marshal(self): + """ + Entry point for pre-serialization processing, for example to finalize + part naming if necessary. May be overridden by subclasses without + forwarding call to super. + """ + # don't place any code here, just catch call if not overridden by + # subclass + pass + + @property + def blob(self): + """ + Contents of this package part as a sequence of bytes. May be text or + binary. Intended to be overridden by subclasses. Default behavior is + to return load blob. + """ + return self._blob + + @property + def content_type(self): + """ + Content type of this part. + """ + return self._content_type + + def drop_rel(self, rId): + """ + Remove the relationship identified by *rId* if its reference count + is less than 2. Relationships with a reference count of 0 are + implicit relationships. + """ + if self._rel_ref_count(rId) < 2: + del self.rels[rId] + + @classmethod + def load(cls, partname, content_type, blob, package): + return cls(partname, content_type, blob, package) + + def load_rel(self, reltype, target, rId, is_external=False): + """ + Return newly added |_Relationship| instance of *reltype* between this + part and *target* with key *rId*. Target mode is set to + ``RTM.EXTERNAL`` if *is_external* is |True|. Intended for use during + load from a serialized package, where the rId is well-known. Other + methods exist for adding a new relationship to a part when + manipulating a part. + """ + return self.rels.add_relationship(reltype, target, rId, is_external) + + @property + def partname(self): + """ + |PackURI| instance holding partname of this part, e.g. + '/ppt/slides/slide1.xml' + """ + return self._partname + + @partname.setter + def partname(self, partname): + if not isinstance(partname, PackURI): + tmpl = "partname must be instance of PackURI, got '%s'" + raise TypeError(tmpl % type(partname).__name__) + self._partname = partname + + @property + def package(self): + """ + |OpcPackage| instance this part belongs to. + """ + return self._package + + def part_related_by(self, reltype): + """ + Return part to which this part has a relationship of *reltype*. + Raises |KeyError| if no such relationship is found and |ValueError| + if more than one such relationship is found. Provides ability to + resolve implicitly related part, such as Slide -> SlideLayout. + """ + return self.rels.part_with_reltype(reltype) + + def relate_to(self, target, reltype, is_external=False): + """ + Return rId key of relationship of *reltype* to *target*, from an + existing relationship if there is one, otherwise a newly created one. + """ + if is_external: + return self.rels.get_or_add_ext_rel(reltype, target) + else: + rel = self.rels.get_or_add(reltype, target) + return rel.rId + + @property + def related_parts(self): + """ + Dictionary mapping related parts by rId, so child objects can resolve + explicit relationships present in the part XML, e.g. sldIdLst to a + specific |Slide| instance. + """ + return self.rels.related_parts + + @lazyproperty + def rels(self): + """ + |Relationships| instance holding the relationships for this part. + """ + return Relationships(self._partname.baseURI) + + def target_ref(self, rId): + """ + Return URL contained in target ref of relationship identified by + *rId*. + """ + rel = self.rels[rId] + return rel.target_ref + + def _rel_ref_count(self, rId): + """ + Return the count of references in this part's XML to the relationship + identified by *rId*. + """ + rIds = self._element.xpath('//@r:id') + return len([_rId for _rId in rIds if _rId == rId]) + + +class PartFactory(object): + """ + Provides a way for client code to specify a subclass of |Part| to be + constructed by |Unmarshaller| based on its content type and/or a custom + callable. Setting ``PartFactory.part_class_selector`` to a callable + object will cause that object to be called with the parameters + ``content_type, reltype``, once for each part in the package. If the + callable returns an object, it is used as the class for that part. If it + returns |None|, part class selection falls back to the content type map + defined in ``PartFactory.part_type_for``. If no class is returned from + either of these, the class contained in ``PartFactory.default_part_type`` + is used to construct the part, which is by default ``opc.package.Part``. + """ + part_class_selector = None + part_type_for = {} + default_part_type = Part + + def __new__(cls, partname, content_type, reltype, blob, package): + PartClass = None + if cls.part_class_selector is not None: + part_class_selector = cls_method_fn(cls, 'part_class_selector') + PartClass = part_class_selector(content_type, reltype) + if PartClass is None: + PartClass = cls._part_cls_for(content_type) + return PartClass.load(partname, content_type, blob, package) + + @classmethod + def _part_cls_for(cls, content_type): + """ + Return the custom part class registered for *content_type*, or the + default part class if no custom class is registered for + *content_type*. + """ + if content_type in cls.part_type_for: + return cls.part_type_for[content_type] + return cls.default_part_type + + +class XmlPart(Part): + """ + Base class for package parts containing an XML payload, which is most of + them. Provides additional methods to the |Part| base class that take care + of parsing and reserializing the XML payload and managing relationships + to other parts. + """ + def __init__(self, partname, content_type, element, package): + super(XmlPart, self).__init__( + partname, content_type, package=package + ) + self._element = element + + @property + def blob(self): + return serialize_part_xml(self._element) + + @classmethod + def load(cls, partname, content_type, blob, package): + element = parse_xml(blob) + return cls(partname, content_type, element, package) + + @property + def part(self): + """ + Part of the parent protocol, "children" of the document will not know + the part that contains them so must ask their parent object. That + chain of delegation ends here for child objects. + """ + return self diff --git a/docx/opc/parts/coreprops.py b/docx/opc/parts/coreprops.py index 748e49cde..c45f4b6a5 100644 --- a/docx/opc/parts/coreprops.py +++ b/docx/opc/parts/coreprops.py @@ -8,7 +8,7 @@ absolute_import, division, print_function, unicode_literals ) -from ..package import XmlPart +from ..part import XmlPart class CorePropertiesPart(XmlPart): diff --git a/docx/parts/document.py b/docx/parts/document.py index e7ff08e8b..abf08b2d4 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -13,7 +13,7 @@ from ..blkcntnr import BlockItemContainer from ..enum.section import WD_SECTION from ..opc.constants import RELATIONSHIP_TYPE as RT -from ..opc.package import XmlPart +from ..opc.part import XmlPart from ..section import Section from ..shape import InlineShape from ..shared import lazyproperty, Parented diff --git a/docx/parts/image.py b/docx/parts/image.py index 9cc698697..6ece20d80 100644 --- a/docx/parts/image.py +++ b/docx/parts/image.py @@ -11,7 +11,7 @@ import hashlib from docx.image.image import Image -from docx.opc.package import Part +from docx.opc.part import Part from docx.shared import Emu, Inches diff --git a/docx/parts/numbering.py b/docx/parts/numbering.py index e9c8f713d..e324c5aac 100644 --- a/docx/parts/numbering.py +++ b/docx/parts/numbering.py @@ -8,7 +8,7 @@ absolute_import, division, print_function, unicode_literals ) -from ..opc.package import XmlPart +from ..opc.part import XmlPart from ..shared import lazyproperty diff --git a/docx/parts/styles.py b/docx/parts/styles.py index d9f4cfda9..d400ce50f 100644 --- a/docx/parts/styles.py +++ b/docx/parts/styles.py @@ -8,7 +8,7 @@ absolute_import, division, print_function, unicode_literals ) -from ..opc.package import XmlPart +from ..opc.part import XmlPart from ..shared import lazyproperty diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index 610c3244d..b746270a3 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -9,20 +9,16 @@ import pytest from docx.opc.coreprops import CoreProperties -from docx.opc.packuri import PACKAGE_URI, PackURI -from docx.opc.package import ( - OpcPackage, Part, PartFactory, Unmarshaller, XmlPart -) +from docx.opc.package import OpcPackage, Unmarshaller +from docx.opc.packuri import PACKAGE_URI +from docx.opc.part import Part from docx.opc.parts.coreprops import CorePropertiesPart from docx.opc.pkgreader import PackageReader from docx.opc.rel import _Relationship, Relationships -from docx.oxml.xmlchemy import BaseOxmlElement -from ..unitutil.cxml import element from ..unitutil.mock import ( - call, class_mock, cls_attr_mock, function_mock, initializer_mock, - instance_mock, loose_mock, method_mock, Mock, patch, PropertyMock, - property_mock + call, class_mock, instance_mock, loose_mock, method_mock, Mock, patch, + PropertyMock, property_mock ) @@ -231,418 +227,6 @@ def Unmarshaller_(self, request): return class_mock(request, 'docx.opc.package.Unmarshaller') -class DescribePart(object): - - def it_can_be_constructed_by_PartFactory(self, load_fixture): - partname_, content_type_, blob_, package_, __init_ = load_fixture - part = Part.load(partname_, content_type_, blob_, package_) - __init_.assert_called_once_with( - partname_, content_type_, blob_, package_ - ) - assert isinstance(part, Part) - - def it_knows_its_partname(self, partname_get_fixture): - part, expected_partname = partname_get_fixture - assert part.partname == expected_partname - - def it_can_change_its_partname(self, partname_set_fixture): - part, new_partname = partname_set_fixture - part.partname = new_partname - assert part.partname == new_partname - - def it_knows_its_content_type(self, content_type_fixture): - part, expected_content_type = content_type_fixture - assert part.content_type == expected_content_type - - def it_knows_the_package_it_belongs_to(self, package_get_fixture): - part, expected_package = package_get_fixture - assert part.package == expected_package - - def it_can_be_notified_after_unmarshalling_is_complete(self, part): - part.after_unmarshal() - - def it_can_be_notified_before_marshalling_is_started(self, part): - part.before_marshal() - - def it_uses_the_load_blob_as_its_blob(self, blob_fixture): - part, load_blob = blob_fixture - assert part.blob is load_blob - - # fixtures --------------------------------------------- - - @pytest.fixture - def blob_fixture(self, blob_): - part = Part(None, None, blob_, None) - return part, blob_ - - @pytest.fixture - def content_type_fixture(self): - content_type = 'content/type' - part = Part(None, content_type, None, None) - return part, content_type - - @pytest.fixture - def load_fixture( - self, request, partname_, content_type_, blob_, package_, - __init_): - return (partname_, content_type_, blob_, package_, __init_) - - @pytest.fixture - def package_get_fixture(self, package_): - part = Part(None, None, None, package_) - return part, package_ - - @pytest.fixture - def part(self): - part = Part(None, None) - return part - - @pytest.fixture - def partname_get_fixture(self): - 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') - part = Part(old_partname, None, None, None) - return part, new_partname - - # fixture components --------------------------------------------- - - @pytest.fixture - def blob_(self, request): - return instance_mock(request, bytes) - - @pytest.fixture - def content_type_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def __init_(self, request): - return initializer_mock(request, Part) - - @pytest.fixture - def package_(self, request): - return instance_mock(request, OpcPackage) - - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) - - -class DescribePartRelationshipManagementInterface(object): - - def it_provides_access_to_its_relationships(self, rels_fixture): - part, Relationships_, partname_, rels_ = rels_fixture - rels = part.rels - Relationships_.assert_called_once_with(partname_.baseURI) - assert rels is rels_ - - 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 - ) - - 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): - 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_) - assert rId is rId_ - - def it_can_drop_a_relationship(self, drop_rel_fixture): - part, rId, rel_should_be_gone = drop_rel_fixture - part.drop_rel(rId) - if rel_should_be_gone: - assert rId not in part.rels - else: - assert rId in part.rels - - def it_can_find_a_related_part_by_reltype(self, related_part_fixture): - part, reltype_, related_part_ = related_part_fixture - related_part = part.part_related_by(reltype_) - part.rels.part_with_reltype.assert_called_once_with(reltype_) - assert related_part is related_part_ - - 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): - part, rId_, url_ = target_ref_fixture - url = part.target_ref(rId_) - assert url == url_ - - # 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 drop_rel_fixture(self, request, part): - part_cxml, rel_should_be_dropped = request.param - rId = 'rId42' - part._element = element(part_cxml) - part._rels = {rId: None} - return part, rId, rel_should_be_dropped - - @pytest.fixture - def load_rel_fixture(self, part, rels_, reltype_, part_, rId_): - part._rels = rels_ - return part, rels_, reltype_, part_, rId_ - - @pytest.fixture - 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_): - part._rels = rels_ - return part, url_, reltype_, rId_ - - @pytest.fixture - def related_part_fixture(self, request, part, rels_, reltype_, part_): - part._rels = rels_ - return part, reltype_, part_ - - @pytest.fixture - def related_parts_fixture(self, request, part, rels_, related_parts_): - part._rels = rels_ - return part, related_parts_ - - @pytest.fixture - def rels_fixture(self, Relationships_, partname_, rels_): - part = Part(partname_, None) - return part, Relationships_, partname_, rels_ - - @pytest.fixture - def target_ref_fixture(self, request, part, rId_, rel_, url_): - part._rels = {rId_: rel_} - return part, rId_, url_ - - # fixture components --------------------------------------------- - - @pytest.fixture - def part(self): - return Part(None, None) - - @pytest.fixture - def part_(self, request): - return instance_mock(request, Part) - - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) - - @pytest.fixture - def Relationships_(self, request, rels_): - return class_mock( - request, 'docx.opc.package.Relationships', return_value=rels_ - ) - - @pytest.fixture - def rel_(self, request, rId_, url_): - return instance_mock( - request, _Relationship, rId=rId_, target_ref=url_ - ) - - @pytest.fixture - def rels_(self, request, part_, rel_, rId_, related_parts_): - rels_ = instance_mock(request, Relationships) - rels_.part_with_reltype.return_value = part_ - rels_.get_or_add.return_value = rel_ - rels_.get_or_add_ext_rel.return_value = rId_ - rels_.related_parts = related_parts_ - return rels_ - - @pytest.fixture - def related_parts_(self, request): - return instance_mock(request, dict) - - @pytest.fixture - def reltype_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def rId_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def url_(self, request): - return instance_mock(request, str) - - -class DescribePartFactory(object): - - 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 - partname, content_type, reltype, blob, package = part_load_params - # exercise --------------------- - PartFactory.part_class_selector = cls_selector_fn_ - part = PartFactory(partname, content_type, reltype, blob, package) - # verify ----------------------- - cls_selector_fn_.assert_called_once_with(content_type, reltype) - CustomPartClass_.load.assert_called_once_with( - partname, content_type, blob, package - ) - 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_): - # fixture ---------------------- - partname, content_type, reltype, package, blob = part_args_ - # exercise --------------------- - PartFactory.part_type_for[content_type] = CustomPartClass_ - part = PartFactory(partname, content_type, reltype, blob, package) - # verify ----------------------- - CustomPartClass_.load.assert_called_once_with( - partname, content_type, blob, package - ) - 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_): - partname, content_type, reltype, blob, package = part_args_2_ - part = PartFactory(partname, content_type, reltype, blob, package) - DefaultPartClass_.load.assert_called_once_with( - partname, content_type, blob, package - ) - assert part is part_of_default_type_ - - # fixtures --------------------------------------------- - - @pytest.fixture - def blob_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def blob_2_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def cls_method_fn_(self, request, cls_selector_fn_): - return function_mock( - request, 'docx.opc.package.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_): - 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_ - ) - - @pytest.fixture - def cls_selector_fn_(self, request, CustomPartClass_): - cls_selector_fn_ = loose_mock(request) - # Python 3 version - cls_selector_fn_.return_value = CustomPartClass_ - # Python 2 version - cls_selector_fn_.__func__ = loose_mock( - request, name='__func__', return_value=cls_selector_fn_ - ) - return cls_selector_fn_ - - @pytest.fixture - def content_type_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def content_type_2_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def CustomPartClass_(self, request, part_of_custom_type_): - CustomPartClass_ = Mock(name='CustomPartClass', spec=Part) - 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_.load.return_value = part_of_default_type_ - return DefaultPartClass_ - - @pytest.fixture - def package_(self, request): - return instance_mock(request, OpcPackage) - - @pytest.fixture - def package_2_(self, request): - return instance_mock(request, OpcPackage) - - @pytest.fixture - def part_load_params( - self, partname_, content_type_, reltype_, blob_, package_): - return partname_, content_type_, reltype_, blob_, package_ - - @pytest.fixture - def part_of_custom_type_(self, request): - return instance_mock(request, Part) - - @pytest.fixture - def part_of_default_type_(self, request): - return instance_mock(request, Part) - - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) - - @pytest.fixture - def partname_2_(self, request): - return instance_mock(request, PackURI) - - @pytest.fixture - 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_): - return partname_2_, content_type_2_, reltype_2_, blob_2_, package_2_ - - @pytest.fixture - def reltype_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def reltype_2_(self, request): - return instance_mock(request, str) - - class DescribeUnmarshaller(object): def it_can_unmarshal_from_a_pkg_reader( @@ -788,86 +372,3 @@ def _unmarshal_parts(self, request, parts_dict_): @pytest.fixture def _unmarshal_relationships(self, request): return method_mock(request, Unmarshaller, '_unmarshal_relationships') - - -class DescribeXmlPart(object): - - def it_can_be_constructed_by_PartFactory(self, load_fixture): - partname_, content_type_, blob_, package_ = load_fixture[:4] - element_, parse_xml_, __init_ = load_fixture[4:] - # exercise --------------------- - part = XmlPart.load(partname_, content_type_, blob_, package_) - # verify ----------------------- - parse_xml_.assert_called_once_with(blob_) - __init_.assert_called_once_with( - partname_, content_type_, element_, package_ - ) - assert isinstance(part, XmlPart) - - def it_can_serialize_to_xml(self, blob_fixture): - xml_part, element_, serialize_part_xml_ = blob_fixture - blob = xml_part.blob - serialize_part_xml_.assert_called_once_with(element_) - assert blob is serialize_part_xml_.return_value - - def it_knows_its_the_part_for_its_child_objects(self, part_fixture): - xml_part = part_fixture - assert xml_part.part is xml_part - - # fixtures ------------------------------------------------------- - - @pytest.fixture - def blob_fixture(self, request, element_, serialize_part_xml_): - xml_part = XmlPart(None, None, element_, None) - return xml_part, element_, serialize_part_xml_ - - @pytest.fixture - def load_fixture( - self, request, partname_, content_type_, blob_, package_, - element_, parse_xml_, __init_): - return ( - partname_, content_type_, blob_, package_, element_, parse_xml_, - __init_ - ) - - @pytest.fixture - def part_fixture(self): - return XmlPart(None, None, None, None) - - # fixture components --------------------------------------------- - - @pytest.fixture - def blob_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def content_type_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def element_(self, request): - return instance_mock(request, BaseOxmlElement) - - @pytest.fixture - def __init_(self, request): - return initializer_mock(request, XmlPart) - - @pytest.fixture - def package_(self, request): - return instance_mock(request, OpcPackage) - - @pytest.fixture - def parse_xml_(self, request, element_): - return function_mock( - request, 'docx.opc.package.parse_xml', return_value=element_ - ) - - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) - - @pytest.fixture - def serialize_part_xml_(self, request): - return function_mock( - request, 'docx.opc.package.serialize_part_xml' - ) diff --git a/tests/opc/test_part.py b/tests/opc/test_part.py new file mode 100644 index 000000000..4721e5be4 --- /dev/null +++ b/tests/opc/test_part.py @@ -0,0 +1,518 @@ +# encoding: utf-8 + +""" +Test suite for docx.opc.part module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.opc.package import OpcPackage +from docx.opc.packuri import PackURI +from docx.opc.part import Part, PartFactory, XmlPart +from docx.opc.rel import _Relationship, Relationships +from docx.oxml.xmlchemy import BaseOxmlElement + +from ..unitutil.cxml import element +from ..unitutil.mock import ( + class_mock, cls_attr_mock, function_mock, initializer_mock, + instance_mock, loose_mock, Mock +) + + +class DescribePart(object): + + def it_can_be_constructed_by_PartFactory(self, load_fixture): + partname_, content_type_, blob_, package_, __init_ = load_fixture + part = Part.load(partname_, content_type_, blob_, package_) + __init_.assert_called_once_with( + partname_, content_type_, blob_, package_ + ) + assert isinstance(part, Part) + + def it_knows_its_partname(self, partname_get_fixture): + part, expected_partname = partname_get_fixture + assert part.partname == expected_partname + + def it_can_change_its_partname(self, partname_set_fixture): + part, new_partname = partname_set_fixture + part.partname = new_partname + assert part.partname == new_partname + + def it_knows_its_content_type(self, content_type_fixture): + part, expected_content_type = content_type_fixture + assert part.content_type == expected_content_type + + def it_knows_the_package_it_belongs_to(self, package_get_fixture): + part, expected_package = package_get_fixture + assert part.package == expected_package + + def it_can_be_notified_after_unmarshalling_is_complete(self, part): + part.after_unmarshal() + + def it_can_be_notified_before_marshalling_is_started(self, part): + part.before_marshal() + + def it_uses_the_load_blob_as_its_blob(self, blob_fixture): + part, load_blob = blob_fixture + assert part.blob is load_blob + + # fixtures --------------------------------------------- + + @pytest.fixture + def blob_fixture(self, blob_): + part = Part(None, None, blob_, None) + return part, blob_ + + @pytest.fixture + def content_type_fixture(self): + content_type = 'content/type' + part = Part(None, content_type, None, None) + return part, content_type + + @pytest.fixture + def load_fixture( + self, request, partname_, content_type_, blob_, package_, + __init_): + return (partname_, content_type_, blob_, package_, __init_) + + @pytest.fixture + def package_get_fixture(self, package_): + part = Part(None, None, None, package_) + return part, package_ + + @pytest.fixture + def part(self): + part = Part(None, None) + return part + + @pytest.fixture + def partname_get_fixture(self): + 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') + part = Part(old_partname, None, None, None) + return part, new_partname + + # fixture components --------------------------------------------- + + @pytest.fixture + def blob_(self, request): + return instance_mock(request, bytes) + + @pytest.fixture + def content_type_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def __init_(self, request): + return initializer_mock(request, Part) + + @pytest.fixture + def package_(self, request): + return instance_mock(request, OpcPackage) + + @pytest.fixture + def partname_(self, request): + return instance_mock(request, PackURI) + + +class DescribePartRelationshipManagementInterface(object): + + def it_provides_access_to_its_relationships(self, rels_fixture): + part, Relationships_, partname_, rels_ = rels_fixture + rels = part.rels + Relationships_.assert_called_once_with(partname_.baseURI) + assert rels is rels_ + + 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 + ) + + 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): + 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_) + assert rId is rId_ + + def it_can_drop_a_relationship(self, drop_rel_fixture): + part, rId, rel_should_be_gone = drop_rel_fixture + part.drop_rel(rId) + if rel_should_be_gone: + assert rId not in part.rels + else: + assert rId in part.rels + + def it_can_find_a_related_part_by_reltype(self, related_part_fixture): + part, reltype_, related_part_ = related_part_fixture + related_part = part.part_related_by(reltype_) + part.rels.part_with_reltype.assert_called_once_with(reltype_) + assert related_part is related_part_ + + 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): + part, rId_, url_ = target_ref_fixture + url = part.target_ref(rId_) + assert url == url_ + + # 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 drop_rel_fixture(self, request, part): + part_cxml, rel_should_be_dropped = request.param + rId = 'rId42' + part._element = element(part_cxml) + part._rels = {rId: None} + return part, rId, rel_should_be_dropped + + @pytest.fixture + def load_rel_fixture(self, part, rels_, reltype_, part_, rId_): + part._rels = rels_ + return part, rels_, reltype_, part_, rId_ + + @pytest.fixture + 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_): + part._rels = rels_ + return part, url_, reltype_, rId_ + + @pytest.fixture + def related_part_fixture(self, request, part, rels_, reltype_, part_): + part._rels = rels_ + return part, reltype_, part_ + + @pytest.fixture + def related_parts_fixture(self, request, part, rels_, related_parts_): + part._rels = rels_ + return part, related_parts_ + + @pytest.fixture + def rels_fixture(self, Relationships_, partname_, rels_): + part = Part(partname_, None) + return part, Relationships_, partname_, rels_ + + @pytest.fixture + def target_ref_fixture(self, request, part, rId_, rel_, url_): + part._rels = {rId_: rel_} + return part, rId_, url_ + + # fixture components --------------------------------------------- + + @pytest.fixture + def part(self): + return Part(None, None) + + @pytest.fixture + def part_(self, request): + return instance_mock(request, Part) + + @pytest.fixture + def partname_(self, request): + return instance_mock(request, PackURI) + + @pytest.fixture + def Relationships_(self, request, 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_ + ) + + @pytest.fixture + def rels_(self, request, part_, rel_, rId_, related_parts_): + rels_ = instance_mock(request, Relationships) + rels_.part_with_reltype.return_value = part_ + rels_.get_or_add.return_value = rel_ + rels_.get_or_add_ext_rel.return_value = rId_ + rels_.related_parts = related_parts_ + return rels_ + + @pytest.fixture + def related_parts_(self, request): + return instance_mock(request, dict) + + @pytest.fixture + def reltype_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def rId_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def url_(self, request): + return instance_mock(request, str) + + +class DescribePartFactory(object): + + 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 + partname, content_type, reltype, blob, package = part_load_params + # exercise --------------------- + PartFactory.part_class_selector = cls_selector_fn_ + part = PartFactory(partname, content_type, reltype, blob, package) + # verify ----------------------- + cls_selector_fn_.assert_called_once_with(content_type, reltype) + CustomPartClass_.load.assert_called_once_with( + partname, content_type, blob, package + ) + 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_): + # fixture ---------------------- + partname, content_type, reltype, package, blob = part_args_ + # exercise --------------------- + PartFactory.part_type_for[content_type] = CustomPartClass_ + part = PartFactory(partname, content_type, reltype, blob, package) + # verify ----------------------- + CustomPartClass_.load.assert_called_once_with( + partname, content_type, blob, package + ) + 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_): + partname, content_type, reltype, blob, package = part_args_2_ + part = PartFactory(partname, content_type, reltype, blob, package) + DefaultPartClass_.load.assert_called_once_with( + partname, content_type, blob, package + ) + assert part is part_of_default_type_ + + # fixtures --------------------------------------------- + + @pytest.fixture + def blob_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def blob_2_(self, request): + return instance_mock(request, str) + + @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_ + ) + + @pytest.fixture + def cls_selector_fixture( + 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_ + ) + + @pytest.fixture + def cls_selector_fn_(self, request, CustomPartClass_): + cls_selector_fn_ = loose_mock(request) + # Python 3 version + cls_selector_fn_.return_value = CustomPartClass_ + # Python 2 version + cls_selector_fn_.__func__ = loose_mock( + request, name='__func__', return_value=cls_selector_fn_ + ) + return cls_selector_fn_ + + @pytest.fixture + def content_type_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def content_type_2_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def CustomPartClass_(self, request, part_of_custom_type_): + CustomPartClass_ = Mock(name='CustomPartClass', spec=Part) + 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_.load.return_value = part_of_default_type_ + return DefaultPartClass_ + + @pytest.fixture + def package_(self, request): + return instance_mock(request, OpcPackage) + + @pytest.fixture + def package_2_(self, request): + return instance_mock(request, OpcPackage) + + @pytest.fixture + def part_load_params( + self, partname_, content_type_, reltype_, blob_, package_): + return partname_, content_type_, reltype_, blob_, package_ + + @pytest.fixture + def part_of_custom_type_(self, request): + return instance_mock(request, Part) + + @pytest.fixture + def part_of_default_type_(self, request): + return instance_mock(request, Part) + + @pytest.fixture + def partname_(self, request): + return instance_mock(request, PackURI) + + @pytest.fixture + def partname_2_(self, request): + return instance_mock(request, PackURI) + + @pytest.fixture + 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_): + return partname_2_, content_type_2_, reltype_2_, blob_2_, package_2_ + + @pytest.fixture + def reltype_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def reltype_2_(self, request): + return instance_mock(request, str) + + +class DescribeXmlPart(object): + + def it_can_be_constructed_by_PartFactory(self, load_fixture): + partname_, content_type_, blob_, package_ = load_fixture[:4] + element_, parse_xml_, __init_ = load_fixture[4:] + # exercise --------------------- + part = XmlPart.load(partname_, content_type_, blob_, package_) + # verify ----------------------- + parse_xml_.assert_called_once_with(blob_) + __init_.assert_called_once_with( + partname_, content_type_, element_, package_ + ) + assert isinstance(part, XmlPart) + + def it_can_serialize_to_xml(self, blob_fixture): + xml_part, element_, serialize_part_xml_ = blob_fixture + blob = xml_part.blob + serialize_part_xml_.assert_called_once_with(element_) + assert blob is serialize_part_xml_.return_value + + def it_knows_its_the_part_for_its_child_objects(self, part_fixture): + xml_part = part_fixture + assert xml_part.part is xml_part + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def blob_fixture(self, request, element_, serialize_part_xml_): + xml_part = XmlPart(None, None, element_, None) + return xml_part, element_, serialize_part_xml_ + + @pytest.fixture + def load_fixture( + self, request, partname_, content_type_, blob_, package_, + element_, parse_xml_, __init_): + return ( + partname_, content_type_, blob_, package_, element_, parse_xml_, + __init_ + ) + + @pytest.fixture + def part_fixture(self): + return XmlPart(None, None, None, None) + + # fixture components --------------------------------------------- + + @pytest.fixture + def blob_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def content_type_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def element_(self, request): + return instance_mock(request, BaseOxmlElement) + + @pytest.fixture + def __init_(self, request): + return initializer_mock(request, XmlPart) + + @pytest.fixture + def package_(self, request): + return instance_mock(request, OpcPackage) + + @pytest.fixture + def parse_xml_(self, request, element_): + return function_mock( + request, 'docx.opc.part.parse_xml', return_value=element_ + ) + + @pytest.fixture + def partname_(self, request): + return instance_mock(request, PackURI) + + @pytest.fixture + def serialize_part_xml_(self, request): + return function_mock( + request, 'docx.opc.part.serialize_part_xml' + ) diff --git a/tests/opc/test_pkgwriter.py b/tests/opc/test_pkgwriter.py index 9e8806f13..d119748dd 100644 --- a/tests/opc/test_pkgwriter.py +++ b/tests/opc/test_pkgwriter.py @@ -7,8 +7,8 @@ import pytest from docx.opc.constants import CONTENT_TYPE as CT -from docx.opc.package import Part from docx.opc.packuri import PackURI +from docx.opc.part import Part from docx.opc.phys_pkg import _ZipPkgWriter from docx.opc.pkgwriter import _ContentTypesItem, PackageWriter diff --git a/tests/opc/test_rel.py b/tests/opc/test_rel.py index db39aa145..db9b52b59 100644 --- a/tests/opc/test_rel.py +++ b/tests/opc/test_rel.py @@ -11,8 +11,8 @@ import pytest from docx.opc.oxml import CT_Relationships -from docx.opc.package import Part from docx.opc.packuri import PackURI +from docx.opc.part import Part from docx.opc.rel import _Relationship, Relationships from ..unitutil.mock import ( diff --git a/tests/opc/unitdata/rels.py b/tests/opc/unitdata/rels.py index f55f4c5f9..94e45167e 100644 --- a/tests/opc/unitdata/rels.py +++ b/tests/opc/unitdata/rels.py @@ -7,7 +7,7 @@ from __future__ import absolute_import from docx.opc.constants import RELATIONSHIP_TYPE as RT -from docx.opc.package import Relationships +from docx.opc.rel import Relationships from docx.opc.constants import NAMESPACE as NS from docx.opc.oxml import parse_xml diff --git a/tests/parts/test_image.py b/tests/parts/test_image.py index 1e1ffc81f..177301345 100644 --- a/tests/parts/test_image.py +++ b/tests/parts/test_image.py @@ -10,8 +10,8 @@ from docx.image.image import Image from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT -from docx.opc.package import PartFactory from docx.opc.packuri import PackURI +from docx.opc.part import PartFactory from docx.package import Package from docx.parts.image import ImagePart From 07127f26dafadff89e42bce5a48a53532cae6f8f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 19:33:31 -0800 Subject: [PATCH 244/809] opc: add OpcPackage._core_properties_part --- docx/__init__.py | 7 +++++- docx/opc/package.py | 8 +++++- docx/opc/parts/coreprops.py | 8 ++++++ tests/opc/test_package.py | 49 +++++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/docx/__init__.py b/docx/__init__.py index f0f3ca482..5a24333a1 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -9,6 +9,7 @@ from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT from docx.opc.part import PartFactory +from docx.opc.parts.coreprops import CorePropertiesPart from docx.parts.document import DocumentPart from docx.parts.image import ImagePart @@ -23,8 +24,12 @@ 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_NUMBERING] = NumberingPart PartFactory.part_type_for[CT.WML_STYLES] = StylesPart -del CT, DocumentPart, PartFactory, part_class_selector +del ( + CT, CorePropertiesPart, DocumentPart, NumberingPart, PartFactory, + StylesPart, part_class_selector +) diff --git a/docx/opc/package.py b/docx/opc/package.py index 024a8e54e..3595f46a6 100644 --- a/docx/opc/package.py +++ b/docx/opc/package.py @@ -10,6 +10,7 @@ from .constants import RELATIONSHIP_TYPE as RT from .packuri import PACKAGE_URI from .part import PartFactory +from .parts.coreprops import CorePropertiesPart from .pkgreader import PackageReader from .pkgwriter import PackageWriter from .rel import Relationships @@ -164,7 +165,12 @@ def _core_properties_part(self): |CorePropertiesPart| object related to this package. Creates a default core properties part if one is not present (not common). """ - raise NotImplementedError + try: + return self.part_related_by(RT.CORE_PROPERTIES) + except KeyError: + core_properties_part = CorePropertiesPart.default(self) + self.relate_to(core_properties_part, RT.CORE_PROPERTIES) + return core_properties_part class Unmarshaller(object): diff --git a/docx/opc/parts/coreprops.py b/docx/opc/parts/coreprops.py index c45f4b6a5..f4ad480ba 100644 --- a/docx/opc/parts/coreprops.py +++ b/docx/opc/parts/coreprops.py @@ -16,6 +16,14 @@ class CorePropertiesPart(XmlPart): Corresponds to part named ``/docProps/core.xml``, containing the core document properties for this document package. """ + @classmethod + def default(cls, package): + """ + Return a new |CorePropertiesPart| object initialized with default + values for its base properties. + """ + raise NotImplementedError + @property def core_properties(self): """ diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index b746270a3..f5b7ac3f7 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -8,6 +8,7 @@ import pytest +from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.coreprops import CoreProperties from docx.opc.package import OpcPackage, Unmarshaller from docx.opc.packuri import PACKAGE_URI @@ -116,6 +117,26 @@ def it_provides_access_to_the_core_properties(self, core_props_fixture): core_properties = opc_package.core_properties assert core_properties is core_properties_ + def it_provides_access_to_the_core_properties_part_to_help( + self, core_props_part_fixture): + opc_package, core_properties_part_ = core_props_part_fixture + core_properties_part = opc_package._core_properties_part + assert core_properties_part is core_properties_part_ + + def it_creates_a_default_core_props_part_if_none_present( + self, default_core_props_fixture): + opc_package, CorePropertiesPart_, core_properties_part_ = ( + default_core_props_fixture + ) + + core_properties_part = opc_package._core_properties_part + + CorePropertiesPart_.default.assert_called_once_with(opc_package) + opc_package.relate_to.assert_called_once_with( + core_properties_part_, RT.CORE_PROPERTIES + ) + assert core_properties_part is core_properties_part_ + # fixtures --------------------------------------------- @pytest.fixture @@ -127,6 +148,22 @@ def core_props_fixture( core_properties_part_.core_properties = core_properties_ return opc_package, core_properties_ + @pytest.fixture + def core_props_part_fixture( + self, part_related_by_, core_properties_part_): + opc_package = OpcPackage() + part_related_by_.return_value = core_properties_part_ + return opc_package, core_properties_part_ + + @pytest.fixture + def default_core_props_fixture( + self, part_related_by_, CorePropertiesPart_, relate_to_, + core_properties_part_): + opc_package = OpcPackage() + part_related_by_.side_effect = KeyError + CorePropertiesPart_.default.return_value = core_properties_part_ + return opc_package, CorePropertiesPart_, core_properties_part_ + @pytest.fixture def relate_to_part_fixture_(self, request, pkg, rels_, reltype): rId = 'rId99' @@ -146,6 +183,10 @@ def related_part_fixture_(self, request, rels_, reltype): # fixture components ----------------------------------- + @pytest.fixture + def CorePropertiesPart_(self, request): + return class_mock(request, 'docx.opc.package.CorePropertiesPart') + @pytest.fixture def core_properties_(self, request): return instance_mock(request, CoreProperties) @@ -170,6 +211,10 @@ def PackageWriter_(self, request): def PartFactory_(self, request): return class_mock(request, 'docx.opc.package.PartFactory') + @pytest.fixture + def part_related_by_(self, request): + return method_mock(request, OpcPackage, 'part_related_by') + @pytest.fixture def parts(self, request, parts_): """ @@ -214,6 +259,10 @@ def rel_attrs_(self, request): rId = 'rId99' return reltype, target_, rId + @pytest.fixture + def relate_to_(self, request): + return method_mock(request, OpcPackage, 'relate_to') + @pytest.fixture def rels_(self, request): return instance_mock(request, Relationships) From 7e92b9afcde06c365c5743b1b52f3d5125196f98 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 20:33:47 -0800 Subject: [PATCH 245/809] opc: add CorePropertiesPart.core_properties --- docx/opc/coreprops.py | 2 ++ docx/opc/part.py | 7 +++++ docx/opc/parts/coreprops.py | 3 ++- docx/oxml/parts/coreprops.py | 21 +++++++++++++++ tests/opc/parts/__init__.py | 0 tests/opc/parts/test_coreprops.py | 43 +++++++++++++++++++++++++++++++ 6 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 docx/oxml/parts/coreprops.py create mode 100644 tests/opc/parts/__init__.py create mode 100644 tests/opc/parts/test_coreprops.py diff --git a/docx/opc/coreprops.py b/docx/opc/coreprops.py index cc8091ef3..516d3e548 100644 --- a/docx/opc/coreprops.py +++ b/docx/opc/coreprops.py @@ -15,3 +15,5 @@ class CoreProperties(object): Corresponds to part named ``/docProps/core.xml``, containing the core document properties for this document package. """ + def __init__(self, element): + self._element = element diff --git a/docx/opc/part.py b/docx/opc/part.py index ed1362110..1196eee5c 100644 --- a/docx/opc/part.py +++ b/docx/opc/part.py @@ -219,6 +219,13 @@ def __init__(self, partname, content_type, element, package): def blob(self): return serialize_part_xml(self._element) + @property + def element(self): + """ + The root XML element of this XML part. + """ + return self._element + @classmethod def load(cls, partname, content_type, blob, package): element = parse_xml(blob) diff --git a/docx/opc/parts/coreprops.py b/docx/opc/parts/coreprops.py index f4ad480ba..14dbd3e76 100644 --- a/docx/opc/parts/coreprops.py +++ b/docx/opc/parts/coreprops.py @@ -8,6 +8,7 @@ absolute_import, division, print_function, unicode_literals ) +from ..coreprops import CoreProperties from ..part import XmlPart @@ -30,4 +31,4 @@ def core_properties(self): A |CoreProperties| object providing read/write access to the core properties contained in this core properties part. """ - raise NotImplementedError + return CoreProperties(self.element) diff --git a/docx/oxml/parts/coreprops.py b/docx/oxml/parts/coreprops.py new file mode 100644 index 000000000..43f3fffd0 --- /dev/null +++ b/docx/oxml/parts/coreprops.py @@ -0,0 +1,21 @@ +# encoding: utf-8 + +""" +lxml custom element classes for core properties-related XML elements. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from ..xmlchemy import BaseOxmlElement + + +class CT_CoreProperties(BaseOxmlElement): + """ + ```` element, the root element of the Core Properties + part stored as ``/docProps/core.xml``. Implements many of the Dublin Core + document metadata elements. String elements resolve to an empty string + ('') if the element is not present in the XML. String elements are + limited in length to 255 unicode characters. + """ diff --git a/tests/opc/parts/__init__.py b/tests/opc/parts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/opc/parts/test_coreprops.py b/tests/opc/parts/test_coreprops.py new file mode 100644 index 000000000..d1585a128 --- /dev/null +++ b/tests/opc/parts/test_coreprops.py @@ -0,0 +1,43 @@ +# encoding: utf-8 + +""" +Unit test suite for the docx.opc.parts.coreprops module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.opc.coreprops import CoreProperties +from docx.opc.parts.coreprops import CorePropertiesPart +from docx.oxml.parts.coreprops import CT_CoreProperties + +from ...unitutil.mock import class_mock, instance_mock + + +class DescribeCorePropertiesPart(object): + + def it_provides_access_to_its_core_props_object(self, coreprops_fixture): + core_properties_part, CoreProperties_ = coreprops_fixture + core_properties = core_properties_part.core_properties + CoreProperties_.assert_called_once_with(core_properties_part.element) + assert isinstance(core_properties, CoreProperties) + + # fixtures --------------------------------------------- + + @pytest.fixture + def coreprops_fixture(self, element_, CoreProperties_): + core_properties_part = CorePropertiesPart(None, None, element_, None) + return core_properties_part, CoreProperties_ + + # fixture components ----------------------------------- + + @pytest.fixture + def CoreProperties_(self, request): + return class_mock(request, 'docx.opc.parts.coreprops.CoreProperties') + + @pytest.fixture + def element_(self, request): + return instance_mock(request, CT_CoreProperties) From 358b408068aa77ad120e8f52d5f9189d9dfbd6a4 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 22:15:07 -0800 Subject: [PATCH 246/809] rfctr: use relative imports in oxml.__init__ --- docx/oxml/__init__.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index b397a1b46..f8d20904d 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -64,9 +64,9 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): # custom element class mappings # =========================================================================== -from docx.oxml.shared import CT_DecimalNumber, CT_OnOff, CT_String +from .shared import CT_DecimalNumber, CT_OnOff, CT_String -from docx.oxml.shape import ( +from .shape import ( CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, CT_GraphicalObjectData, CT_Inline, CT_NonVisualDrawingProps, CT_Picture, CT_PictureNonVisual, CT_Point2D, CT_PositiveSize2D, CT_ShapeProperties, @@ -87,11 +87,11 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('wp:extent', CT_PositiveSize2D) register_element_cls('wp:inline', CT_Inline) -from docx.oxml.parts.document import CT_Body, CT_Document +from .parts.document import CT_Body, CT_Document register_element_cls('w:body', CT_Body) register_element_cls('w:document', CT_Document) -from docx.oxml.parts.numbering import ( +from .parts.numbering import ( CT_Num, CT_Numbering, CT_NumLvl, CT_NumPr ) register_element_cls('w:abstractNumId', CT_DecimalNumber) @@ -103,17 +103,17 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:numbering', CT_Numbering) register_element_cls('w:startOverride', CT_DecimalNumber) -from docx.oxml.parts.styles import CT_Style, CT_Styles +from .parts.styles import CT_Style, CT_Styles register_element_cls('w:style', CT_Style) register_element_cls('w:styles', CT_Styles) -from docx.oxml.section import CT_PageMar, CT_PageSz, CT_SectPr, CT_SectType +from .section import CT_PageMar, CT_PageSz, CT_SectPr, CT_SectType register_element_cls('w:pgMar', CT_PageMar) register_element_cls('w:pgSz', CT_PageSz) register_element_cls('w:sectPr', CT_SectPr) register_element_cls('w:type', CT_SectType) -from docx.oxml.table import ( +from .table import ( CT_Row, CT_Tbl, CT_TblGrid, CT_TblGridCol, CT_TblLayoutType, CT_TblPr, CT_TblWidth, CT_Tc, CT_TcPr, CT_VMerge ) @@ -130,7 +130,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:tr', CT_Row) register_element_cls('w:vMerge', CT_VMerge) -from docx.oxml.text import ( +from .text import ( CT_Br, CT_Jc, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text, CT_Underline ) register_element_cls('w:b', CT_OnOff) From 4d2c4656ba8cb7e7e2ab479663c5916f97e14143 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 22:13:01 -0800 Subject: [PATCH 247/809] opc: transplant CoreProperties --- docx/opc/coreprops.py | 120 ++++++++++++++ docx/oxml/__init__.py | 4 + docx/oxml/ns.py | 8 +- docx/oxml/parts/coreprops.py | 285 ++++++++++++++++++++++++++++++++- features/doc-coreprops.feature | 2 +- tests/opc/test_coreprops.py | 180 +++++++++++++++++++++ 6 files changed, 596 insertions(+), 3 deletions(-) create mode 100644 tests/opc/test_coreprops.py diff --git a/docx/opc/coreprops.py b/docx/opc/coreprops.py index 516d3e548..2d38dabd3 100644 --- a/docx/opc/coreprops.py +++ b/docx/opc/coreprops.py @@ -17,3 +17,123 @@ class CoreProperties(object): """ def __init__(self, element): self._element = element + + @property + def author(self): + return self._element.author_text + + @author.setter + def author(self, value): + self._element.author_text = value + + @property + def category(self): + return self._element.category_text + + @category.setter + def category(self, value): + self._element.category_text = value + + @property + def comments(self): + return self._element.comments_text + + @comments.setter + def comments(self, value): + self._element.comments_text = value + + @property + def content_status(self): + return self._element.contentStatus_text + + @content_status.setter + def content_status(self, value): + self._element.contentStatus_text = value + + @property + def created(self): + return self._element.created_datetime + + @created.setter + def created(self, value): + self._element.created_datetime = value + + @property + def identifier(self): + return self._element.identifier_text + + @identifier.setter + def identifier(self, value): + self._element.identifier_text = value + + @property + def keywords(self): + return self._element.keywords_text + + @keywords.setter + def keywords(self, value): + self._element.keywords_text = value + + @property + def language(self): + return self._element.language_text + + @language.setter + def language(self, value): + self._element.language_text = value + + @property + def last_modified_by(self): + return self._element.lastModifiedBy_text + + @last_modified_by.setter + def last_modified_by(self, value): + self._element.lastModifiedBy_text = value + + @property + def last_printed(self): + return self._element.lastPrinted_datetime + + @last_printed.setter + def last_printed(self, value): + self._element.lastPrinted_datetime = value + + @property + def modified(self): + return self._element.modified_datetime + + @modified.setter + def modified(self, value): + self._element.modified_datetime = value + + @property + def revision(self): + return self._element.revision_number + + @revision.setter + def revision(self, value): + self._element.revision_number = value + + @property + def subject(self): + return self._element.subject_text + + @subject.setter + def subject(self, value): + self._element.subject_text = value + + @property + def title(self): + return self._element.title_text + + @title.setter + def title(self, value): + self._element.title_text = value + + @property + def version(self): + return self._element.version_text + + @version.setter + def version(self, value): + self._element.version_text = value diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index f8d20904d..73cb5010d 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -87,6 +87,10 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('wp:extent', CT_PositiveSize2D) register_element_cls('wp:inline', CT_Inline) + +from .parts.coreprops import CT_CoreProperties +register_element_cls('cp:coreProperties', CT_CoreProperties) + from .parts.document import CT_Body, CT_Document register_element_cls('w:body', CT_Body) register_element_cls('w:document', CT_Document) diff --git a/docx/oxml/ns.py b/docx/oxml/ns.py index d4b3014db..e6f6a4acc 100644 --- a/docx/oxml/ns.py +++ b/docx/oxml/ns.py @@ -10,6 +10,11 @@ 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-pr' + 'operties'), + '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'), 'pic': ('http://schemas.openxmlformats.org/drawingml/2006/picture'), 'r': ('http://schemas.openxmlformats.org/officeDocument/2006/relations' @@ -17,7 +22,8 @@ 'w': ('http://schemas.openxmlformats.org/wordprocessingml/2006/main'), 'wp': ('http://schemas.openxmlformats.org/drawingml/2006/wordprocessing' 'Drawing'), - 'xml': ('http://www.w3.org/XML/1998/namespace') + '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/oxml/parts/coreprops.py b/docx/oxml/parts/coreprops.py index 43f3fffd0..746d1372a 100644 --- a/docx/oxml/parts/coreprops.py +++ b/docx/oxml/parts/coreprops.py @@ -8,7 +8,12 @@ absolute_import, division, print_function, unicode_literals ) -from ..xmlchemy import BaseOxmlElement +import re + +from datetime import datetime, timedelta + +from ..ns import qn +from ..xmlchemy import BaseOxmlElement, ZeroOrOne class CT_CoreProperties(BaseOxmlElement): @@ -19,3 +24,281 @@ 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=()) + + @property + def author_text(self): + """ + The text in the `dc:creator` child element. + """ + return self._text_of_element('creator') + + @author_text.setter + def author_text(self, value): + self._set_element_text('creator', value) + + @property + def category_text(self): + return self._text_of_element('category') + + @category_text.setter + def category_text(self, value): + self._set_element_text('category', value) + + @property + def comments_text(self): + return self._text_of_element('description') + + @comments_text.setter + def comments_text(self, value): + self._set_element_text('description', value) + + @property + def contentStatus_text(self): + return self._text_of_element('contentStatus') + + @contentStatus_text.setter + def contentStatus_text(self, value): + self._set_element_text('contentStatus', value) + + @property + def created_datetime(self): + return self._datetime_of_element('created') + + @created_datetime.setter + def created_datetime(self, value): + self._set_element_datetime('created', value) + + @property + def identifier_text(self): + return self._text_of_element('identifier') + + @identifier_text.setter + def identifier_text(self, value): + self._set_element_text('identifier', value) + + @property + def keywords_text(self): + return self._text_of_element('keywords') + + @keywords_text.setter + def keywords_text(self, value): + self._set_element_text('keywords', value) + + @property + def language_text(self): + return self._text_of_element('language') + + @language_text.setter + def language_text(self, value): + self._set_element_text('language', value) + + @property + def lastModifiedBy_text(self): + return self._text_of_element('lastModifiedBy') + + @lastModifiedBy_text.setter + def lastModifiedBy_text(self, value): + self._set_element_text('lastModifiedBy', value) + + @property + def lastPrinted_datetime(self): + return self._datetime_of_element('lastPrinted') + + @lastPrinted_datetime.setter + def lastPrinted_datetime(self, value): + self._set_element_datetime('lastPrinted', value) + + @property + def modified_datetime(self): + return self._datetime_of_element('modified') + + @modified_datetime.setter + def modified_datetime(self, value): + self._set_element_datetime('modified', value) + + @property + def revision_number(self): + """ + Integer value of revision property. + """ + revision = self.revision + if revision is None: + return 0 + revision_str = revision.text + try: + revision = int(revision_str) + except ValueError: + # non-integer revision strings also resolve to 0 + revision = 0 + # as do negative integers + if revision < 0: + revision = 0 + return revision + + @revision_number.setter + def revision_number(self, value): + """ + Set revision property to string value of integer *value*. + """ + if not isinstance(value, int) or value < 1: + tmpl = "revision property requires positive int, got '%s'" + raise ValueError(tmpl % value) + revision = self.get_or_add_revision() + revision.text = str(value) + + @property + def subject_text(self): + return self._text_of_element('subject') + + @subject_text.setter + def subject_text(self, value): + self._set_element_text('subject', value) + + @property + def title_text(self): + return self._text_of_element('title') + + @title_text.setter + def title_text(self, value): + self._set_element_text('title', value) + + @property + def version_text(self): + return self._text_of_element('version') + + @version_text.setter + def version_text(self, value): + self._set_element_text('version', value) + + def _datetime_of_element(self, property_name): + element = getattr(self, property_name) + if element is None: + return None + datetime_str = element.text + try: + return self._parse_W3CDTF_to_datetime(datetime_str) + except ValueError: + # invalid datetime strings are ignored + return None + + 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 = getattr(self, get_or_add_method_name) + element = get_or_add_method() + return element + + @classmethod + def _offset_dt(cls, dt, offset_str): + """ + Return a |datetime| instance that is offset from datetime *dt* by + the timezone offset specified in *offset_str*, a string like + ``'-07:00'``. + """ + match = cls._offset_pattern.match(offset_str) + if match is None: + 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 + 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('([+-])(\d\d):(\d\d)') + + @classmethod + def _parse_W3CDTF_to_datetime(cls, w3cdtf_str): + # valid W3CDTF date cases: + # yyyy e.g. '2003' + # yyyy-mm e.g. '2003-12' + # 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', + ) + # 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] + offset_str = w3cdtf_str[19:] + dt = None + for tmpl in templates: + try: + dt = datetime.strptime(parseable_part, tmpl) + except ValueError: + continue + if dt is None: + tmpl = "could not parse W3CDTF datetime string '%s'" + raise ValueError(tmpl % w3cdtf_str) + if len(offset_str) == 6: + return cls._offset_dt(dt, offset_str) + return dt + + 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" + ) + raise ValueError(tmpl % type(value)) + element = self._get_or_add(prop_name) + dt_str = value.strftime('%Y-%m-%dT%H:%M:%SZ') + element.text = dt_str + 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')] + + def _set_element_text(self, prop_name, value): + """ + Set string value of *name* property to *value*. + """ + value = str(value) + if len(value) > 255: + 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 + + def _text_of_element(self, property_name): + """ + Return the text in the element matching *property_name*, or an empty + string if the element is not present or contains no text. + """ + element = getattr(self, property_name) + if element is None: + return '' + if element.text is None: + return '' + return element.text diff --git a/features/doc-coreprops.feature b/features/doc-coreprops.feature index 0d84b9862..e255be718 100644 --- a/features/doc-coreprops.feature +++ b/features/doc-coreprops.feature @@ -3,7 +3,7 @@ Feature: Read and write core document properties As a developer using python-docx I need to access and modify the Dublin Core metadata for a document - @wip + Scenario: read the core properties of a document Given a document having known core properties Then I can access the core properties object diff --git a/tests/opc/test_coreprops.py b/tests/opc/test_coreprops.py new file mode 100644 index 000000000..3f6cfa935 --- /dev/null +++ b/tests/opc/test_coreprops.py @@ -0,0 +1,180 @@ +# encoding: utf-8 + +""" +Unit test suite for the docx.opc.coreprops module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from datetime import datetime + +from docx.opc.coreprops import CoreProperties +from docx.oxml import parse_xml + + +class DescribeCoreProperties(object): + + def it_knows_the_string_property_values(self, text_prop_get_fixture): + core_properties, prop_name, expected_value = text_prop_get_fixture + actual_value = getattr(core_properties, prop_name) + assert actual_value == expected_value + + def it_can_change_the_string_property_values(self, text_prop_set_fixture): + core_properties, prop_name, value, expected_xml = text_prop_set_fixture + setattr(core_properties, prop_name, value) + assert core_properties._element.xml == expected_xml + + def it_knows_the_date_property_values(self, date_prop_get_fixture): + core_properties, prop_name, expected_datetime = date_prop_get_fixture + actual_datetime = getattr(core_properties, prop_name) + assert actual_datetime == expected_datetime + + def it_can_change_the_date_property_values(self, date_prop_set_fixture): + core_properties, prop_name, value, expected_xml = ( + date_prop_set_fixture + ) + setattr(core_properties, prop_name, value) + assert core_properties._element.xml == expected_xml + + def it_knows_the_revision_number(self, revision_get_fixture): + core_properties, expected_revision = revision_get_fixture + assert core_properties.revision == expected_revision + + def it_can_change_the_revision_number(self, revision_set_fixture): + core_properties, revision, expected_xml = revision_set_fixture + core_properties.revision = revision + assert core_properties._element.xml == expected_xml + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ('created', datetime(2012, 11, 17, 16, 37, 40)), + ('last_printed', datetime(2014, 6, 4, 4, 28)), + ('modified', None), + ]) + def date_prop_get_fixture(self, request, core_properties): + prop_name, expected_datetime = request.param + return core_properties, prop_name, expected_datetime + + @pytest.fixture(params=[ + ('created', 'dcterms:created', datetime(2001, 2, 3, 4, 5), + '2001-02-03T04:05:00Z', ' xsi:type="dcterms:W3CDTF"'), + ('last_printed', 'cp:lastPrinted', datetime(2014, 6, 4, 4), + '2014-06-04T04:00:00Z', ''), + ('modified', 'dcterms:modified', datetime(2005, 4, 3, 2, 1), + '2005-04-03T02:01:00Z', ' xsi:type="dcterms:W3CDTF"'), + ]) + def date_prop_set_fixture(self, request): + prop_name, tagname, value, str_val, attrs = request.param + coreProperties = self.coreProperties(None, None) + core_properties = CoreProperties(parse_xml(coreProperties)) + expected_xml = self.coreProperties(tagname, str_val, attrs) + return core_properties, prop_name, value, expected_xml + + @pytest.fixture(params=[ + ('42', 42), (None, 0), ('foobar', 0), ('-17', 0), ('32.7', 0) + ]) + def revision_get_fixture(self, request): + str_val, expected_revision = request.param + tagname = '' if str_val is None else 'cp:revision' + coreProperties = self.coreProperties(tagname, str_val) + core_properties = CoreProperties(parse_xml(coreProperties)) + return core_properties, expected_revision + + @pytest.fixture(params=[ + (42, '42'), + ]) + def revision_set_fixture(self, request): + value, str_val = request.param + coreProperties = self.coreProperties(None, None) + core_properties = CoreProperties(parse_xml(coreProperties)) + expected_xml = self.coreProperties('cp:revision', str_val) + return core_properties, value, expected_xml + + @pytest.fixture(params=[ + ('author', 'python-pptx'), + ('category', ''), + ('comments', ''), + ('content_status', 'DRAFT'), + ('identifier', 'GXS 10.2.1ab'), + ('keywords', 'foo bar baz'), + ('language', 'US-EN'), + ('last_modified_by', 'Steve Canny'), + ('subject', 'Spam'), + ('title', 'Presentation'), + ('version', '1.2.88'), + ]) + def text_prop_get_fixture(self, request, core_properties): + prop_name, expected_value = request.param + return core_properties, prop_name, expected_value + + @pytest.fixture(params=[ + ('author', 'dc:creator', 'scanny'), + ('category', 'cp:category', 'silly stories'), + ('comments', 'dc:description', 'Bar foo to you'), + ('content_status', 'cp:contentStatus', 'FINAL'), + ('identifier', 'dc:identifier', 'GT 5.2.xab'), + ('keywords', 'cp:keywords', 'dog cat moo'), + ('language', 'dc:language', 'GB-EN'), + ('last_modified_by', 'cp:lastModifiedBy', 'Billy Bob'), + ('subject', 'dc:subject', 'Eggs'), + ('title', 'dc:title', 'Dissertation'), + ('version', 'cp:version', '81.2.8'), + ]) + def text_prop_set_fixture(self, request): + prop_name, tagname, value = request.param + coreProperties = self.coreProperties(None, None) + core_properties = CoreProperties(parse_xml(coreProperties)) + expected_xml = self.coreProperties(tagname, value) + return core_properties, prop_name, value, expected_xml + + # fixture components --------------------------------------------- + + def coreProperties(self, tagname, str_val, attrs=''): + tmpl = ( + '%s\n' + ) + if not tagname: + child_element = '' + elif not str_val: + child_element = '\n <%s%s/>\n' % (tagname, attrs) + else: + child_element = ( + '\n <%s%s>%s\n' % (tagname, attrs, str_val, tagname) + ) + return tmpl % child_element + + @pytest.fixture + def core_properties(self): + element = parse_xml( + b'' + b'\n\n' + b' DRAFT\n' + b' python-pptx\n' + b' 2012-11-17T11:07:' + b'40-05:30\n' + b' \n' + b' GXS 10.2.1ab\n' + b' US-EN\n' + b' 2014-06-04T04:28:00Z\n' + b' foo bar baz\n' + b' Steve Canny\n' + b' 4\n' + b' Spam\n' + b' Presentation\n' + b' 1.2.88\n' + b'\n' + ) + return CoreProperties(element) From d49d71f81df5125ec745c4a7de910a2d3dad34f4 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 22:39:15 -0800 Subject: [PATCH 248/809] acpt: add scenario for writing CoreProperties --- features/doc-coreprops.feature | 6 ++++++ features/steps/coreprops.py | 38 +++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/features/doc-coreprops.feature b/features/doc-coreprops.feature index e255be718..b8b9eb312 100644 --- a/features/doc-coreprops.feature +++ b/features/doc-coreprops.feature @@ -8,3 +8,9 @@ Feature: Read and write core document properties Given a document having known core properties Then I can access the core properties object And the core property values match the known values + + + Scenario: change the core properties of a document + Given a document having known core properties + When I assign new values to the properties + Then the core property values match the new values diff --git a/features/steps/coreprops.py b/features/steps/coreprops.py index 41767b8b6..950f3c5cb 100644 --- a/features/steps/coreprops.py +++ b/features/steps/coreprops.py @@ -10,7 +10,7 @@ from datetime import datetime -from behave import given, then +from behave import given, then, when from docx import Document from docx.opc.coreprops import CoreProperties @@ -25,6 +25,32 @@ def given_a_document_having_known_core_properties(context): context.document = Document(test_docx('doc-coreprops')) +# when ==================================================== + +@when("I assign new values to the properties") +def when_I_assign_new_values_to_the_properties(context): + context.propvals = ( + ('author', 'Creator'), + ('category', 'Category'), + ('comments', 'Description'), + ('content_status', 'Content Status'), + ('created', datetime(2013, 6, 15, 12, 34, 56)), + ('identifier', 'Identifier'), + ('keywords', 'key; word; keyword'), + ('language', 'Language'), + ('last_modified_by', 'Last Modified By'), + ('last_printed', datetime(2013, 6, 15, 12, 34, 56)), + ('modified', datetime(2013, 6, 15, 12, 34, 56)), + ('revision', 9), + ('subject', 'Subject'), + ('title', 'Title'), + ('version', 'Version'), + ) + core_properties = context.document.core_properties + for name, value in context.propvals: + setattr(core_properties, name, value) + + # then ==================================================== @then('I can access the core properties object') @@ -59,3 +85,13 @@ def then_the_core_property_values_match_the_known_values(context): assert value == expected_value, ( "got '%s' for core property '%s'" % (value, name) ) + + +@then('the core property values match the new values') +def then_the_core_property_values_match_the_new_values(context): + core_properties = context.document.core_properties + for name, expected_value in context.propvals: + value = getattr(core_properties, name) + assert value == expected_value, ( + "got '%s' for core property '%s'" % (value, name) + ) From 113c9991833d6448e1c64a8cd9741e46c7f6d950 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 22:53:46 -0800 Subject: [PATCH 249/809] acpt: add scenario for default CoreProperties part --- features/doc-coreprops.feature | 7 +++++ features/steps/coreprops.py | 25 +++++++++++++++++- .../steps/test_files/doc-no-coreprops.docx | Bin 0 -> 11394 bytes 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 features/steps/test_files/doc-no-coreprops.docx diff --git a/features/doc-coreprops.feature b/features/doc-coreprops.feature index b8b9eb312..7ebdae669 100644 --- a/features/doc-coreprops.feature +++ b/features/doc-coreprops.feature @@ -14,3 +14,10 @@ Feature: Read and write core document properties Given a document having known core properties When I assign new values to the properties Then the core property values match the new values + + + @wip + Scenario: a default core properties part is added if doc doesn't have one + Given a document having no core properties part + When I access the core properties object + Then a core properties part with default values is added diff --git a/features/steps/coreprops.py b/features/steps/coreprops.py index 950f3c5cb..dc6be2e6c 100644 --- a/features/steps/coreprops.py +++ b/features/steps/coreprops.py @@ -8,7 +8,7 @@ absolute_import, division, print_function, unicode_literals ) -from datetime import datetime +from datetime import datetime, timedelta from behave import given, then, when @@ -25,8 +25,18 @@ def given_a_document_having_known_core_properties(context): context.document = Document(test_docx('doc-coreprops')) +@given('a document having no core properties part') +def given_a_document_having_no_core_properties_part(context): + context.document = Document(test_docx('doc-no-coreprops')) + + # when ==================================================== +@when('I access the core properties object') +def when_I_access_the_core_properties_object(context): + context.document.core_properties + + @when("I assign new values to the properties") def when_I_assign_new_values_to_the_properties(context): context.propvals = ( @@ -53,6 +63,19 @@ def when_I_assign_new_values_to_the_properties(context): # then ==================================================== +@then('a core properties part with default values is added') +def then_a_core_properties_part_with_default_values_is_added(context): + core_properties = context.document.core_properties + assert core_properties.title == 'Word Document' + assert core_properties.last_modified_by == 'python-docx' + assert core_properties.revision == 1 + # core_properties.modified only stores time with seconds resolution, so + # comparison needs to be a little loose (within two seconds) + modified_timedelta = datetime.utcnow() - core_properties.modified + max_expected_timedelta = timedelta(seconds=2) + assert modified_timedelta < max_expected_timedelta + + @then('I can access the core properties object') def then_I_can_access_the_core_properties_object(context): document = context.document diff --git a/features/steps/test_files/doc-no-coreprops.docx b/features/steps/test_files/doc-no-coreprops.docx new file mode 100644 index 0000000000000000000000000000000000000000..588bf557f85f99d48a4b632c7217c5b7e22cb267 GIT binary patch literal 11394 zcmbW71yr0#)9(kj;O_3O!QI{6-EDBoKnNCe@Sq_;aDux-f(3`*1a~L6T(Z0GyStq4 z-gD2#93IZ}@UQ9V>8@WrRi&x`1&sv&0N?>rbum)(Sl~D?zK^xSLZwSux@d;Vk&dPxJd)NLqQIp?}NV! z5=Mw1L9i00`!V0PKta>eqj;(#tT3HJbz@?^1+4VEVY(YmM_hK|T^bA)4Q>HhSi3#mEb(tGhyIxD_N((ESa|we9q%G8vPxWG#%9Zpe$nIf%wb6x(~!%5K!vX9Udh z3>*0f&NfY3G4nj8t4PW$D=)0u27{5`eZ&1|7d~1(>g&9znaCov?2cN@j7V+vsd6#1 z0Yf~uLos#NI`18a0WwM+v|$Pw3u^V5lk&+&R}MPF_*rP_@cfCwH?=X45D8?JYJP`{ zq2?$#E;>+i94_7Jsu!{WDXzZY7*9-;d?d(Hn{CIW`7KEXX4E#%;8^gJ%*{%RS}|tH zVdp3Qn`syEfro<>)vSD?Goe!>rBaoHl z_q7~6Di6hu7Jht(WyZ?qX=5NM`QcD1bXjr_Dth3++2cIivgB}qP;BZQj{iy8%5cC< znq?bbqg<=nAde4qsuClNFLCs*w_uf)yS^B#^l`$>5L)WEq|L(8nK&-;)O5l5G^yb0 zvb_YpWk^dQaL;0>Y`f-dJdrzJAq2&q4iUVzlRxtA>rY1v=V@2 zV^`XfMq~am*(!^W9;Rnu!xV)aIkW04K8jFPB}!wc)FU8v9vGt@z2eO`^4|r50+uB> zOxxE)LbX$ovfeoWgY9EW7u7L==U6mcuc=o9Y+?~hQsG=>{vH8pUu>awq!#8+TTRE|$C_b~LhiYsgij%F`qGpU$wX}16T0m` z6Uy_D$U|4C`_f=K50T>&yoj*ldT7%_ zxI=_lxnH+79Ho{&m&^&1_Q^bb`SXB_D;|wOA>0=Xtb)_SUC-VTRcL|r z{xf;?ZD8ypUzAb!k|ucQOPK9qbX>pcU|!9Xxqh=_liUgtQ*;Pde;8M)0U|CR5jN

>9!)DBQj43=}|5!4c+&rZJ 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 Date: Sat, 13 Dec 2014 23:23:17 -0800 Subject: [PATCH 250/809] opc: add CorePropertiesPart.default() --- docx/opc/parts/coreprops.py | 22 +++++++++++++++++++++- docx/oxml/parts/coreprops.py | 16 +++++++++++++++- features/doc-coreprops.feature | 1 - tests/opc/parts/test_coreprops.py | 13 +++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/docx/opc/parts/coreprops.py b/docx/opc/parts/coreprops.py index 14dbd3e76..080e0f81f 100644 --- a/docx/opc/parts/coreprops.py +++ b/docx/opc/parts/coreprops.py @@ -8,7 +8,12 @@ absolute_import, division, print_function, unicode_literals ) +from datetime import datetime + +from ..constants import CONTENT_TYPE as CT from ..coreprops import CoreProperties +from ...oxml.parts.coreprops import CT_CoreProperties +from ..packuri import PackURI from ..part import XmlPart @@ -23,7 +28,13 @@ def default(cls, package): Return a new |CorePropertiesPart| object initialized with default values for its base properties. """ - raise NotImplementedError + core_properties_part = cls._new(package) + core_properties = core_properties_part.core_properties + core_properties.title = 'Word Document' + core_properties.last_modified_by = 'python-docx' + core_properties.revision = 1 + core_properties.modified = datetime.utcnow() + return core_properties_part @property def core_properties(self): @@ -32,3 +43,12 @@ def core_properties(self): properties contained in this core properties part. """ return CoreProperties(self.element) + + @classmethod + def _new(cls, package): + partname = PackURI('/docProps/core.xml') + content_type = CT.OPC_CORE_PROPERTIES + coreProperties = CT_CoreProperties.new() + return CorePropertiesPart( + partname, content_type, coreProperties, package + ) diff --git a/docx/oxml/parts/coreprops.py b/docx/oxml/parts/coreprops.py index 746d1372a..fbd73cb94 100644 --- a/docx/oxml/parts/coreprops.py +++ b/docx/oxml/parts/coreprops.py @@ -12,7 +12,8 @@ from datetime import datetime, timedelta -from ..ns import qn +from .. import parse_xml +from ..ns import nsdecls, qn from ..xmlchemy import BaseOxmlElement, ZeroOrOne @@ -40,6 +41,19 @@ class CT_CoreProperties(BaseOxmlElement): title = ZeroOrOne('dc:title', successors=()) version = ZeroOrOne('cp:version', successors=()) + _coreProperties_tmpl = ( + '\n' % nsdecls('cp', 'dc', 'dcterms') + ) + + @classmethod + def new(cls): + """ + Return a new ```` element + """ + xml = cls._coreProperties_tmpl + coreProperties = parse_xml(xml) + return coreProperties + @property def author_text(self): """ diff --git a/features/doc-coreprops.feature b/features/doc-coreprops.feature index 7ebdae669..15a5724c3 100644 --- a/features/doc-coreprops.feature +++ b/features/doc-coreprops.feature @@ -16,7 +16,6 @@ Feature: Read and write core document properties Then the core property values match the new values - @wip Scenario: a default core properties part is added if doc doesn't have one Given a document having no core properties part When I access the core properties object diff --git a/tests/opc/parts/test_coreprops.py b/tests/opc/parts/test_coreprops.py index d1585a128..f324f15db 100644 --- a/tests/opc/parts/test_coreprops.py +++ b/tests/opc/parts/test_coreprops.py @@ -8,6 +8,8 @@ absolute_import, division, print_function, unicode_literals ) +from datetime import datetime, timedelta + import pytest from docx.opc.coreprops import CoreProperties @@ -25,6 +27,17 @@ def it_provides_access_to_its_core_props_object(self, coreprops_fixture): CoreProperties_.assert_called_once_with(core_properties_part.element) assert isinstance(core_properties, CoreProperties) + def it_can_create_a_default_core_properties_part(self): + core_properties_part = CorePropertiesPart.default(None) + assert isinstance(core_properties_part, CorePropertiesPart) + core_properties = core_properties_part.core_properties + assert core_properties.title == 'Word Document' + assert core_properties.last_modified_by == 'python-docx' + assert core_properties.revision == 1 + delta = datetime.utcnow() - core_properties.modified + max_expected_delta = timedelta(seconds=2) + assert delta < max_expected_delta + # fixtures --------------------------------------------- @pytest.fixture From b6e20505211af3b9426b1ab1b3dd043cd31aca85 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 14 Dec 2014 00:00:02 -0800 Subject: [PATCH 251/809] docs: add API documentation for CoreProperties --- docs/api/document.rst | 96 +++++++++++++++++++++++++++++++++++++++++++ docs/conf.py | 4 ++ 2 files changed, 100 insertions(+) diff --git a/docs/api/document.rst b/docs/api/document.rst index accab05b3..6fae9ea26 100644 --- a/docs/api/document.rst +++ b/docs/api/document.rst @@ -28,3 +28,99 @@ The main Document and related objects. .. autoclass:: Sections :members: + + +|CoreProperties| objects +------------------------- + +Each |Document| object provides access to its |CoreProperties| object via its +:attr:`core_properties` attribute. A |CoreProperties| object provides +read/write access to the so-called *core properties* for the document. The +core properties are author, category, comments, content_status, created, +identifier, keywords, language, last_modified_by, last_printed, modified, +revision, subject, title, and version. + +Each property is one of three types, |str|, |datetime|, or |int|. String +properties are limited in length to 255 characters and return an empty string +('') if not set. Date properties are assigned and returned as |datetime| +objects without timezone, i.e. in UTC. Any timezone conversions are the +responsibility of the client. Date properties return |None| if not set. + +|docx| does not automatically set any of the document core properties other +than to add a core properties part to a presentation that doesn't have one +(very uncommon). If |docx| adds a core properties part, it contains default +values for the title, last_modified_by, revision, and modified properties. +Client code should update properties like revision and last_modified_by +if that behavior is desired. + +.. currentmodule:: docx.opc.coreprops + +.. class:: CoreProperties + + .. attribute:: author + + *string* -- An entity primarily responsible for making the content of the + resource. + + .. attribute:: category + + *string* -- A categorization of the content of this package. Example + values might include: Resume, Letter, Financial Forecast, Proposal, + or Technical Presentation. + + .. attribute:: comments + + *string* -- An account of the content of the resource. + + .. attribute:: content_status + + *string* -- completion status of the document, e.g. 'draft' + + .. attribute:: created + + *datetime* -- time of intial creation of the document + + .. attribute:: identifier + + *string* -- An unambiguous reference to the resource within a given + context, e.g. ISBN. + + .. attribute:: keywords + + *string* -- descriptive words or short phrases likely to be used as + search terms for this document + + .. attribute:: language + + *string* -- language the document is written in + + .. attribute:: last_modified_by + + *string* -- name or other identifier (such as email address) of person + who last modified the document + + .. attribute:: last_printed + + *datetime* -- time the document was last printed + + .. attribute:: modified + + *datetime* -- time the document was last modified + + .. attribute:: revision + + *int* -- number of this revision, incremented by Word each time the + document is saved. Note however |docx| does not automatically increment + the revision number when it saves a document. + + .. attribute:: subject + + *string* -- The topic of the content of the resource. + + .. attribute:: title + + *string* -- The name given to the resource. + + .. attribute:: version + + *string* -- free-form version string diff --git a/docs/conf.py b/docs/conf.py index 46f243600..7549dbd65 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -79,6 +79,8 @@ .. |CoreProperties| replace:: :class:`.CoreProperties` +.. |datetime| replace:: :class:`datetime.datetime` + .. |Document| replace:: :class:`.Document` .. |docx| replace:: ``python-docx`` @@ -121,6 +123,8 @@ .. |Sections| replace:: :class:`.Sections` +.. |str| replace:: :class:`str` + .. |StylesPart| replace:: :class:`.StylesPart` .. |Table| replace:: :class:`.Table` From 344b370f0d53f9af554f8e8f71a89c4ffd5c4a8b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 14 Dec 2014 00:20:39 -0800 Subject: [PATCH 252/809] release: prepare v0.7.6 release --- HISTORY.rst | 7 +++++++ docx/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 0e0537148..4462daa10 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,13 @@ Release History --------------- +0.7.6 (2014-12-14) +++++++++++++++++++ + +- Add feature #69: Table.alignment +- Add feature #29: Document.core_properties + + 0.7.5 (2014-11-29) ++++++++++++++++++ diff --git a/docx/__init__.py b/docx/__init__.py index 5a24333a1..3672e23d1 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.7.5' +__version__ = '0.7.6' # register custom Part classes with opc package reader From 68e52c8f6e1b72457f6f65cf260905f0c34ee2f5 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 14 Dec 2014 13:07:07 -0800 Subject: [PATCH 253/809] opc: change pasted pptx reference to docx --- tests/opc/test_coreprops.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/opc/test_coreprops.py b/tests/opc/test_coreprops.py index 3f6cfa935..47195f597 100644 --- a/tests/opc/test_coreprops.py +++ b/tests/opc/test_coreprops.py @@ -96,7 +96,7 @@ def revision_set_fixture(self, request): return core_properties, value, expected_xml @pytest.fixture(params=[ - ('author', 'python-pptx'), + ('author', 'python-docx'), ('category', ''), ('comments', ''), ('content_status', 'DRAFT'), @@ -105,7 +105,7 @@ def revision_set_fixture(self, request): ('language', 'US-EN'), ('last_modified_by', 'Steve Canny'), ('subject', 'Spam'), - ('title', 'Presentation'), + ('title', 'Word Document'), ('version', '1.2.88'), ]) def text_prop_get_fixture(self, request, core_properties): @@ -162,7 +162,7 @@ def core_properties(self): b'itype/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="h' b'ttp://www.w3.org/2001/XMLSchema-instance">\n' b' DRAFT\n' - b' python-pptx\n' + b' python-docx\n' b' 2012-11-17T11:07:' b'40-05:30\n' b' \n' @@ -173,7 +173,7 @@ def core_properties(self): b' Steve Canny\n' b' 4\n' b' Spam\n' - b' Presentation\n' + b' Word Document\n' b' 1.2.88\n' b'\n' ) From 378d206cd40591a39441e87be31a5cc2752bd931 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 14 Dec 2014 23:24:09 -0800 Subject: [PATCH 254/809] tbl: enable sliced access to _Rows --- docx/table.py | 11 +++-------- tests/test_table.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/docx/table.py b/docx/table.py index 424bee541..0038de1a7 100644 --- a/docx/table.py +++ b/docx/table.py @@ -370,8 +370,8 @@ def _index(self): class _Rows(Parented): """ - Sequence of |_Row| instances corresponding to the rows in a table. - Supports ``len()``, iteration and indexed access. + Sequence of |_Row| objects corresponding to the rows in a table. + Supports ``len()``, iteration, indexed access, and slicing. """ def __init__(self, tbl, parent): super(_Rows, self).__init__(parent) @@ -381,12 +381,7 @@ def __getitem__(self, idx): """ Provide indexed access, (e.g. 'rows[0]') """ - try: - tr = self._tbl.tr_lst[idx] - except IndexError: - msg = "row index [%d] out of range" % idx - raise IndexError(msg) - return _Row(tr, self) + return list(self)[idx] def __iter__(self): return (_Row(tr, self) for tr in self._tbl.tr_lst) diff --git a/tests/test_table.py b/tests/test_table.py index 107574688..349b2f51e 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -678,6 +678,15 @@ def it_provides_indexed_access_to_rows(self, rows_fixture): row = rows[idx] assert isinstance(row, _Row) + def it_provides_sliced_access_to_rows(self, slice_fixture): + rows, start, end, expected_count = slice_fixture + slice_of_rows = rows[start:end] + assert len(slice_of_rows) == expected_count + tr_lst = rows._tbl.tr_lst + for idx, row in enumerate(slice_of_rows): + assert tr_lst.index(row._tr) == start + idx + assert isinstance(row, _Row) + def it_raises_on_indexed_access_out_of_range(self, rows_fixture): rows, row_count = rows_fixture with pytest.raises(IndexError): @@ -700,6 +709,16 @@ def rows_fixture(self): rows = _Rows(tbl, None) return rows, row_count + @pytest.fixture(params=[ + (3, 1, 3, 2), + (3, 0, -1, 2), + ]) + def slice_fixture(self, request): + row_count, start, end, expected_count = request.param + tbl = _tbl_bldr(rows=row_count, cols=2).element + rows = _Rows(tbl, None) + return rows, start, end, expected_count + @pytest.fixture def table_fixture(self, table_): rows = _Rows(None, table_) From cd292280ffba5ec13c15f203c99bcb8c67be866c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 14:21:53 -0800 Subject: [PATCH 255/809] reorg: mv docx/text.py => docx/text/__init__.py First step in extracting text module into a sub-package. --- docx/{text.py => text/__init__.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename docx/{text.py => text/__init__.py} (99%) diff --git a/docx/text.py b/docx/text/__init__.py similarity index 99% rename from docx/text.py rename to docx/text/__init__.py index 0c551beeb..21131c3d0 100644 --- a/docx/text.py +++ b/docx/text/__init__.py @@ -6,8 +6,8 @@ from __future__ import absolute_import, print_function, unicode_literals -from .enum.text import WD_BREAK -from .shared import Parented +from ..enum.text import WD_BREAK +from ..shared import Parented def boolproperty(f): From 67c901b2acdf8fcbee590692fc88347bbc0330c7 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 14:43:13 -0800 Subject: [PATCH 256/809] reorg: mv oxml/text.py => oxml/text/__init__.py In preparation to extract text subpackage. --- docx/oxml/{text.py => text/__init__.py} | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) rename docx/oxml/{text.py => text/__init__.py} (98%) diff --git a/docx/oxml/text.py b/docx/oxml/text/__init__.py similarity index 98% rename from docx/oxml/text.py rename to docx/oxml/text/__init__.py index 9fdd1d64b..ce044cc5c 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text/__init__.py @@ -5,10 +5,10 @@ (CT_R). """ -from ..enum.text import WD_ALIGN_PARAGRAPH, WD_UNDERLINE -from .ns import qn -from .simpletypes import ST_BrClear, ST_BrType -from .xmlchemy import ( +from ...enum.text import WD_ALIGN_PARAGRAPH, WD_UNDERLINE +from ..ns import qn +from ..simpletypes import ST_BrClear, ST_BrType +from ..xmlchemy import ( BaseOxmlElement, OptionalAttribute, OxmlElement, RequiredAttribute, ZeroOrMore, ZeroOrOne ) From 78f9107f4ad8ff2eec528475d218ba33f3575b17 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 15:34:25 -0800 Subject: [PATCH 257/809] reorg: extract tests.oxml.text subpackage --- tests/oxml/text/__init__.py | 0 tests/oxml/{test_text.py => text/test_run.py} | 8 +++++--- 2 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 tests/oxml/text/__init__.py rename tests/oxml/{test_text.py => text/test_run.py} (82%) diff --git a/tests/oxml/text/__init__.py b/tests/oxml/text/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/oxml/test_text.py b/tests/oxml/text/test_run.py similarity index 82% rename from tests/oxml/test_text.py rename to tests/oxml/text/test_run.py index 974e19504..57b8580fe 100644 --- a/tests/oxml/test_text.py +++ b/tests/oxml/text/test_run.py @@ -1,14 +1,16 @@ # encoding: utf-8 """ -Test suite for the docx.oxml.text module. +Test suite for the docx.oxml.text.run module. """ -from __future__ import absolute_import, print_function, unicode_literals +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) import pytest -from ..unitutil.cxml import element, xml +from ...unitutil.cxml import element, xml class DescribeCT_R(object): From afc60c139b40aaacf3b2e090fb5c9b14da2f21f6 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 15:35:57 -0800 Subject: [PATCH 258/809] reorg: extract docx.oxml.text.run module --- docx/oxml/__init__.py | 14 +- docx/oxml/text/__init__.py | 279 +--------------------------------- docx/oxml/text/run.py | 285 +++++++++++++++++++++++++++++++++++ tests/parts/test_document.py | 2 +- tests/test_text.py | 3 +- 5 files changed, 297 insertions(+), 286 deletions(-) create mode 100644 docx/oxml/text/run.py diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 73cb5010d..1cae55f02 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -134,9 +134,13 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:tr', CT_Row) register_element_cls('w:vMerge', CT_VMerge) -from .text import ( - CT_Br, CT_Jc, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text, CT_Underline -) +from .text import CT_Jc, CT_P, CT_PPr +register_element_cls('w:jc', CT_Jc) +register_element_cls('w:p', CT_P) +register_element_cls('w:pPr', CT_PPr) +register_element_cls('w:pStyle', CT_String) + +from .text.run import CT_Br, CT_R, CT_RPr, CT_Text, CT_Underline register_element_cls('w:b', CT_OnOff) register_element_cls('w:bCs', CT_OnOff) register_element_cls('w:br', CT_Br) @@ -147,13 +151,9 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): 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:jc', CT_Jc) 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:p', CT_P) -register_element_cls('w:pPr', CT_PPr) -register_element_cls('w:pStyle', CT_String) register_element_cls('w:r', CT_R) register_element_cls('w:rPr', CT_RPr) register_element_cls('w:rStyle', CT_String) diff --git a/docx/oxml/text/__init__.py b/docx/oxml/text/__init__.py index ce044cc5c..f2f3067d6 100644 --- a/docx/oxml/text/__init__.py +++ b/docx/oxml/text/__init__.py @@ -5,23 +5,13 @@ (CT_R). """ -from ...enum.text import WD_ALIGN_PARAGRAPH, WD_UNDERLINE +from ...enum.text import WD_ALIGN_PARAGRAPH from ..ns import qn -from ..simpletypes import ST_BrClear, ST_BrType from ..xmlchemy import ( - BaseOxmlElement, OptionalAttribute, OxmlElement, RequiredAttribute, - ZeroOrMore, ZeroOrOne + BaseOxmlElement, OxmlElement, RequiredAttribute, ZeroOrMore, ZeroOrOne ) -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) - - class CT_Jc(BaseOxmlElement): """ ```` element, specifying paragraph justification. @@ -164,268 +154,3 @@ def style(self, style): return pStyle = self.get_or_add_pStyle() pStyle.val = style - - -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') - - def _insert_rPr(self, rPr): - self.insert(0, rPr) - return rPr - - 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') - return t - - def add_drawing(self, inline_or_anchor): - """ - Return a newly appended ``CT_Drawing`` (````) child - element having *inline_or_anchor* as its child. - """ - drawing = self._add_drawing() - drawing.append(inline_or_anchor) - return drawing - - def clear_content(self): - """ - Remove all child elements except the ```` element if present. - """ - content_child_elms = self[1:] if self.rPr is not None else self[:] - for child in content_child_elms: - self.remove(child) - - @property - def style(self): - """ - String contained in w:val attribute of grandchild, or - |None| if that element is not present. - """ - rPr = self.rPr - if rPr is None: - return None - return rPr.style - - @style.setter - def style(self, style): - """ - Set the character style of this element to *style*. If *style* - is None, remove the style element. - """ - rPr = self.get_or_add_rPr() - rPr.style = style - - @property - def text(self): - """ - A string representing the textual content of this run, with content - child elements like ```` translated to their Python - equivalent. - """ - text = '' - for child in self: - 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' - return text - - @text.setter - def text(self, text): - self.clear_content() - _RunContentAppender.append_to_run_from_text(self, text) - - @property - def underline(self): - """ - String contained in w:val attribute of ./w:rPr/w:u grandchild, or - |None| if not present. - """ - rPr = self.rPr - if rPr is None: - return None - return rPr.underline - - @underline.setter - def underline(self, value): - rPr = self.get_or_add_rPr() - rPr.underline = value - - -class CT_RPr(BaseOxmlElement): - """ - ```` element, containing the properties for a run. - """ - rStyle = ZeroOrOne('w:rStyle', successors=('w:rPrChange',)) - b = ZeroOrOne('w:b', successors=('w:rPrChange',)) - bCs = ZeroOrOne('w:bCs', successors=('w:rPrChange',)) - caps = ZeroOrOne('w:caps', successors=('w:rPrChange',)) - cs = ZeroOrOne('w:cs', successors=('w:rPrChange',)) - dstrike = ZeroOrOne('w:dstrike', successors=('w:rPrChange',)) - emboss = ZeroOrOne('w:emboss', successors=('w:rPrChange',)) - i = ZeroOrOne('w:i', successors=('w:rPrChange',)) - iCs = ZeroOrOne('w:iCs', successors=('w:rPrChange',)) - imprint = ZeroOrOne('w:imprint', successors=('w:rPrChange',)) - noProof = ZeroOrOne('w:noProof', successors=('w:rPrChange',)) - oMath = ZeroOrOne('w:oMath', successors=('w:rPrChange',)) - outline = ZeroOrOne('w:outline', successors=('w:rPrChange',)) - rtl = ZeroOrOne('w:rtl', successors=('w:rPrChange',)) - shadow = ZeroOrOne('w:shadow', successors=('w:rPrChange',)) - smallCaps = ZeroOrOne('w:smallCaps', successors=('w:rPrChange',)) - snapToGrid = ZeroOrOne('w:snapToGrid', successors=('w:rPrChange',)) - specVanish = ZeroOrOne('w:specVanish', successors=('w:rPrChange',)) - strike = ZeroOrOne('w:strike', successors=('w:rPrChange',)) - u = ZeroOrOne('w:u', successors=('w:rPrChange',)) - vanish = ZeroOrOne('w:vanish', successors=('w:rPrChange',)) - webHidden = ZeroOrOne('w:webHidden', successors=('w:rPrChange',)) - - @property - def style(self): - """ - String contained in child, or None if that element is not - present. - """ - rStyle = self.rStyle - if rStyle is None: - return None - return rStyle.val - - @style.setter - def style(self, style): - """ - Set val attribute of child element to *style*, adding a - new element if necessary. If *style* is |None|, remove the - element if present. - """ - if style is None: - self._remove_rStyle() - elif self.rStyle is None: - self._add_rStyle(val=style) - else: - self.rStyle.val = style - - @property - def underline(self): - """ - Underline type specified in child, or None if that element is - not present. - """ - u = self.u - if u is None: - return None - return u.val - - @underline.setter - def underline(self, value): - self._remove_u() - if value is not None: - u = self._add_u() - u.val = value - - -class CT_Text(BaseOxmlElement): - """ - ```` element, containing a sequence of characters within a run. - """ - - -class CT_Underline(BaseOxmlElement): - """ - ```` element, specifying the underlining style for a run. - """ - @property - def val(self): - """ - The underline type corresponding to the ``w:val`` attribute value. - """ - val = self.get(qn('w:val')) - underline = WD_UNDERLINE.from_xml(val) - if underline == WD_UNDERLINE.SINGLE: - return True - if underline == WD_UNDERLINE.NONE: - return False - return underline - - @val.setter - def val(self, value): - # works fine without these two mappings, but only because True == 1 - # and False == 0, which happen to match the mapping for WD_UNDERLINE - # .SINGLE and .NONE respectively. - if value is True: - value = WD_UNDERLINE.SINGLE - elif value is False: - value = WD_UNDERLINE.NONE - - val = WD_UNDERLINE.to_xml(value) - self.set(qn('w:val'), val) - - -class _RunContentAppender(object): - """ - Service object that knows how to translate a Python string into run - content elements appended to a specified ```` element. Contiguous - sequences of regular characters are appended in a single ```` - element. Each tab character ('\t') causes a ```` element to be - 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 = [] - - @classmethod - def append_to_run_from_text(cls, r, text): - """ - Create a "one-shot" ``_RunContentAppender`` instance and use it to - append the run content elements corresponding to *text* to the - ```` element *r*. - """ - appender = cls(r) - appender.add_text(text) - - def add_text(self, text): - """ - Append the run content elements corresponding to *text* to the - ```` element of this instance. - """ - for char in text: - self.add_char(char) - self.flush() - - def add_char(self, char): - """ - Process the next character of input through the translation finite - state maching (FSM). There are two possible states, buffer pending - and not pending, but those are hidden behind the ``.flush()`` method - which must be called at the end of text to ensure any pending - ```` element is written. - """ - if char == '\t': - self.flush() - self._r.add_tab() - elif char in '\r\n': - self.flush() - self._r.add_br() - else: - self._bfr.append(char) - - def flush(self): - text = ''.join(self._bfr) - if text: - self._r.add_t(text) - del self._bfr[:] diff --git a/docx/oxml/text/run.py b/docx/oxml/text/run.py new file mode 100644 index 000000000..a7ffeb32c --- /dev/null +++ b/docx/oxml/text/run.py @@ -0,0 +1,285 @@ +# encoding: utf-8 + +""" +Custom element classes related to text runs (CT_R). +""" + +from ...enum.text import WD_UNDERLINE +from ..ns import qn +from ..simpletypes import ST_BrClear, ST_BrType +from ..xmlchemy import ( + BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne +) + + +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) + + +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') + + def _insert_rPr(self, rPr): + self.insert(0, rPr) + return rPr + + 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') + return t + + def add_drawing(self, inline_or_anchor): + """ + Return a newly appended ``CT_Drawing`` (````) child + element having *inline_or_anchor* as its child. + """ + drawing = self._add_drawing() + drawing.append(inline_or_anchor) + return drawing + + def clear_content(self): + """ + Remove all child elements except the ```` element if present. + """ + content_child_elms = self[1:] if self.rPr is not None else self[:] + for child in content_child_elms: + self.remove(child) + + @property + def style(self): + """ + String contained in w:val attribute of grandchild, or + |None| if that element is not present. + """ + rPr = self.rPr + if rPr is None: + return None + return rPr.style + + @style.setter + def style(self, style): + """ + Set the character style of this element to *style*. If *style* + is None, remove the style element. + """ + rPr = self.get_or_add_rPr() + rPr.style = style + + @property + def text(self): + """ + A string representing the textual content of this run, with content + child elements like ```` translated to their Python + equivalent. + """ + text = '' + for child in self: + 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' + return text + + @text.setter + def text(self, text): + self.clear_content() + _RunContentAppender.append_to_run_from_text(self, text) + + @property + def underline(self): + """ + String contained in w:val attribute of ./w:rPr/w:u grandchild, or + |None| if not present. + """ + rPr = self.rPr + if rPr is None: + return None + return rPr.underline + + @underline.setter + def underline(self, value): + rPr = self.get_or_add_rPr() + rPr.underline = value + + +class CT_RPr(BaseOxmlElement): + """ + ```` element, containing the properties for a run. + """ + rStyle = ZeroOrOne('w:rStyle', successors=('w:rPrChange',)) + b = ZeroOrOne('w:b', successors=('w:rPrChange',)) + bCs = ZeroOrOne('w:bCs', successors=('w:rPrChange',)) + caps = ZeroOrOne('w:caps', successors=('w:rPrChange',)) + cs = ZeroOrOne('w:cs', successors=('w:rPrChange',)) + dstrike = ZeroOrOne('w:dstrike', successors=('w:rPrChange',)) + emboss = ZeroOrOne('w:emboss', successors=('w:rPrChange',)) + i = ZeroOrOne('w:i', successors=('w:rPrChange',)) + iCs = ZeroOrOne('w:iCs', successors=('w:rPrChange',)) + imprint = ZeroOrOne('w:imprint', successors=('w:rPrChange',)) + noProof = ZeroOrOne('w:noProof', successors=('w:rPrChange',)) + oMath = ZeroOrOne('w:oMath', successors=('w:rPrChange',)) + outline = ZeroOrOne('w:outline', successors=('w:rPrChange',)) + rtl = ZeroOrOne('w:rtl', successors=('w:rPrChange',)) + shadow = ZeroOrOne('w:shadow', successors=('w:rPrChange',)) + smallCaps = ZeroOrOne('w:smallCaps', successors=('w:rPrChange',)) + snapToGrid = ZeroOrOne('w:snapToGrid', successors=('w:rPrChange',)) + specVanish = ZeroOrOne('w:specVanish', successors=('w:rPrChange',)) + strike = ZeroOrOne('w:strike', successors=('w:rPrChange',)) + u = ZeroOrOne('w:u', successors=('w:rPrChange',)) + vanish = ZeroOrOne('w:vanish', successors=('w:rPrChange',)) + webHidden = ZeroOrOne('w:webHidden', successors=('w:rPrChange',)) + + @property + def style(self): + """ + String contained in child, or None if that element is not + present. + """ + rStyle = self.rStyle + if rStyle is None: + return None + return rStyle.val + + @style.setter + def style(self, style): + """ + Set val attribute of child element to *style*, adding a + new element if necessary. If *style* is |None|, remove the + element if present. + """ + if style is None: + self._remove_rStyle() + elif self.rStyle is None: + self._add_rStyle(val=style) + else: + self.rStyle.val = style + + @property + def underline(self): + """ + Underline type specified in child, or None if that element is + not present. + """ + u = self.u + if u is None: + return None + return u.val + + @underline.setter + def underline(self, value): + self._remove_u() + if value is not None: + u = self._add_u() + u.val = value + + +class CT_Text(BaseOxmlElement): + """ + ```` element, containing a sequence of characters within a run. + """ + + +class CT_Underline(BaseOxmlElement): + """ + ```` element, specifying the underlining style for a run. + """ + @property + def val(self): + """ + The underline type corresponding to the ``w:val`` attribute value. + """ + val = self.get(qn('w:val')) + underline = WD_UNDERLINE.from_xml(val) + if underline == WD_UNDERLINE.SINGLE: + return True + if underline == WD_UNDERLINE.NONE: + return False + return underline + + @val.setter + def val(self, value): + # works fine without these two mappings, but only because True == 1 + # and False == 0, which happen to match the mapping for WD_UNDERLINE + # .SINGLE and .NONE respectively. + if value is True: + value = WD_UNDERLINE.SINGLE + elif value is False: + value = WD_UNDERLINE.NONE + + val = WD_UNDERLINE.to_xml(value) + self.set(qn('w:val'), val) + + +class _RunContentAppender(object): + """ + Service object that knows how to translate a Python string into run + content elements appended to a specified ```` element. Contiguous + sequences of regular characters are appended in a single ```` + element. Each tab character ('\t') causes a ```` element to be + 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 = [] + + @classmethod + def append_to_run_from_text(cls, r, text): + """ + Create a "one-shot" ``_RunContentAppender`` instance and use it to + append the run content elements corresponding to *text* to the + ```` element *r*. + """ + appender = cls(r) + appender.add_text(text) + + def add_text(self, text): + """ + Append the run content elements corresponding to *text* to the + ```` element of this instance. + """ + for char in text: + self.add_char(char) + self.flush() + + def add_char(self, char): + """ + Process the next character of input through the translation finite + state maching (FSM). There are two possible states, buffer pending + and not pending, but those are hidden behind the ``.flush()`` method + which must be called at the end of text to ensure any pending + ```` element is written. + """ + if char == '\t': + self.flush() + self._r.add_tab() + elif char in '\r\n': + self.flush() + self._r.add_br() + else: + self._bfr.append(char) + + def flush(self): + text = ''.join(self._bfr) + if text: + self._r.add_t(text) + del self._bfr[:] diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 26d0ff901..7f94e28bf 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -11,7 +11,7 @@ from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.oxml.parts.document import CT_Body, CT_Document from docx.oxml.section import CT_SectPr -from docx.oxml.text import CT_R +from docx.oxml.text.run import CT_R from docx.package import ImageParts, Package from docx.parts.document import _Body, DocumentPart, InlineShapes, Sections from docx.parts.image import ImagePart diff --git a/tests/test_text.py b/tests/test_text.py index f918c6d2f..eba0cc222 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -10,7 +10,8 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_UNDERLINE from docx.oxml.ns import qn -from docx.oxml.text import CT_P, CT_R +from docx.oxml.text import CT_P +from docx.oxml.text.run import CT_R from docx.parts.document import InlineShapes from docx.shape import InlineShape from docx.text import Paragraph, Run From 5b924f4934fbadd33e8c590e909d520ec57dc66f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 15:54:24 -0800 Subject: [PATCH 259/809] reorg: extract docx.oxml.text.paragraph module --- docx/oxml/__init__.py | 2 +- docx/oxml/text/__init__.py | 156 ------------------------------------ docx/oxml/text/paragraph.py | 155 +++++++++++++++++++++++++++++++++++ tests/test_text.py | 2 +- 4 files changed, 157 insertions(+), 158 deletions(-) create mode 100644 docx/oxml/text/paragraph.py diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 1cae55f02..a61f65943 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -134,7 +134,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:tr', CT_Row) register_element_cls('w:vMerge', CT_VMerge) -from .text import CT_Jc, CT_P, CT_PPr +from .text.paragraph import CT_Jc, CT_P, CT_PPr register_element_cls('w:jc', CT_Jc) register_element_cls('w:p', CT_P) register_element_cls('w:pPr', CT_PPr) diff --git a/docx/oxml/text/__init__.py b/docx/oxml/text/__init__.py index f2f3067d6..e69de29bb 100644 --- a/docx/oxml/text/__init__.py +++ b/docx/oxml/text/__init__.py @@ -1,156 +0,0 @@ -# encoding: utf-8 - -""" -Custom element classes related to text, such as paragraph (CT_P) and runs -(CT_R). -""" - -from ...enum.text import WD_ALIGN_PARAGRAPH -from ..ns import qn -from ..xmlchemy import ( - BaseOxmlElement, OxmlElement, RequiredAttribute, ZeroOrMore, ZeroOrOne -) - - -class CT_Jc(BaseOxmlElement): - """ - ```` element, specifying paragraph justification. - """ - val = RequiredAttribute('w:val', WD_ALIGN_PARAGRAPH) - - -class CT_P(BaseOxmlElement): - """ - ```` element, containing the properties and text for a paragraph. - """ - pPr = ZeroOrOne('w:pPr') - r = ZeroOrMore('w:r') - - def _insert_pPr(self, pPr): - self.insert(0, pPr) - return pPr - - def add_p_before(self): - """ - Return a new ```` element inserted directly prior to this one. - """ - new_p = OxmlElement('w:p') - self.addprevious(new_p) - return new_p - - @property - def alignment(self): - """ - The value of the ```` grandchild element or |None| if not - present. - """ - pPr = self.pPr - if pPr is None: - return None - return pPr.alignment - - @alignment.setter - def alignment(self, value): - pPr = self.get_or_add_pPr() - pPr.alignment = value - - def clear_content(self): - """ - Remove all child elements, except the ```` element if present. - """ - for child in self[:]: - if child.tag == qn('w:pPr'): - continue - self.remove(child) - - def set_sectPr(self, sectPr): - """ - Unconditionally replace or add *sectPr* as a grandchild in the - correct sequence. - """ - pPr = self.get_or_add_pPr() - pPr._remove_sectPr() - pPr._insert_sectPr(sectPr) - - @property - def style(self): - """ - String contained in w:val attribute of ./w:pPr/w:pStyle grandchild, - or |None| if not present. - """ - pPr = self.pPr - if pPr is None: - return None - return pPr.style - - @style.setter - def style(self, style): - pPr = self.get_or_add_pPr() - pPr.style = style - - -class CT_PPr(BaseOxmlElement): - """ - ```` element, containing the properties for a paragraph. - """ - __child_sequence__ = ( - 'w:pStyle', 'w:keepNext', 'w:keepLines', 'w:pageBreakBefore', - 'w:framePr', 'w:widowControl', 'w:numPr', 'w:suppressLineNumbers', - 'w:pBdr', 'w:shd', 'w:tabs', 'w:suppressAutoHyphens', 'w:kinsoku', - 'w:wordWrap', 'w:overflowPunct', 'w:topLinePunct', 'w:autoSpaceDE', - 'w:autoSpaceDN', 'w:bidi', 'w:adjustRightInd', 'w:snapToGrid', - 'w:spacing', 'w:ind', 'w:contextualSpacing', 'w:mirrorIndents', - 'w:suppressOverlap', 'w:jc', 'w:textDirection', 'w:textAlignment', - 'w:textboxTightWrap', 'w:outlineLvl', 'w:divId', 'w:cnfStyle', - 'w:rPr', 'w:sectPr', 'w:pPrChange' - ) - pStyle = ZeroOrOne('w:pStyle') - numPr = ZeroOrOne('w:numPr', successors=__child_sequence__[7:]) - jc = ZeroOrOne('w:jc', successors=__child_sequence__[27:]) - sectPr = ZeroOrOne('w:sectPr', successors=('w:pPrChange',)) - - def _insert_pStyle(self, pStyle): - self.insert(0, pStyle) - return pStyle - - @property - def alignment(self): - """ - The value of the ```` child element or |None| if not present. - """ - jc = self.jc - if jc is None: - return None - return jc.val - - @alignment.setter - def alignment(self, value): - if value is None: - self._remove_jc() - return - jc = self.get_or_add_jc() - jc.val = value - - @property - def style(self): - """ - String contained in child, or None if that element is not - present. - """ - pStyle = self.pStyle - if pStyle is None: - return None - return pStyle.val - - @style.setter - def style(self, style): - """ - Set val attribute of child element to *style*, adding a - new element if necessary. If *style* is |None|, remove the - element if present. - """ - if style is None: - self._remove_pStyle() - return - pStyle = self.get_or_add_pStyle() - pStyle.val = style diff --git a/docx/oxml/text/paragraph.py b/docx/oxml/text/paragraph.py new file mode 100644 index 000000000..055ed6adf --- /dev/null +++ b/docx/oxml/text/paragraph.py @@ -0,0 +1,155 @@ +# encoding: utf-8 + +""" +Custom element classes related to paragraphs (CT_P). +""" + +from ...enum.text import WD_ALIGN_PARAGRAPH +from ..ns import qn +from ..xmlchemy import ( + BaseOxmlElement, OxmlElement, RequiredAttribute, ZeroOrMore, ZeroOrOne +) + + +class CT_Jc(BaseOxmlElement): + """ + ```` element, specifying paragraph justification. + """ + val = RequiredAttribute('w:val', WD_ALIGN_PARAGRAPH) + + +class CT_P(BaseOxmlElement): + """ + ```` element, containing the properties and text for a paragraph. + """ + pPr = ZeroOrOne('w:pPr') + r = ZeroOrMore('w:r') + + def _insert_pPr(self, pPr): + self.insert(0, pPr) + return pPr + + def add_p_before(self): + """ + Return a new ```` element inserted directly prior to this one. + """ + new_p = OxmlElement('w:p') + self.addprevious(new_p) + return new_p + + @property + def alignment(self): + """ + The value of the ```` grandchild element or |None| if not + present. + """ + pPr = self.pPr + if pPr is None: + return None + return pPr.alignment + + @alignment.setter + def alignment(self, value): + pPr = self.get_or_add_pPr() + pPr.alignment = value + + def clear_content(self): + """ + Remove all child elements, except the ```` element if present. + """ + for child in self[:]: + if child.tag == qn('w:pPr'): + continue + self.remove(child) + + def set_sectPr(self, sectPr): + """ + Unconditionally replace or add *sectPr* as a grandchild in the + correct sequence. + """ + pPr = self.get_or_add_pPr() + pPr._remove_sectPr() + pPr._insert_sectPr(sectPr) + + @property + def style(self): + """ + String contained in w:val attribute of ./w:pPr/w:pStyle grandchild, + or |None| if not present. + """ + pPr = self.pPr + if pPr is None: + return None + return pPr.style + + @style.setter + def style(self, style): + pPr = self.get_or_add_pPr() + pPr.style = style + + +class CT_PPr(BaseOxmlElement): + """ + ```` element, containing the properties for a paragraph. + """ + __child_sequence__ = ( + 'w:pStyle', 'w:keepNext', 'w:keepLines', 'w:pageBreakBefore', + 'w:framePr', 'w:widowControl', 'w:numPr', 'w:suppressLineNumbers', + 'w:pBdr', 'w:shd', 'w:tabs', 'w:suppressAutoHyphens', 'w:kinsoku', + 'w:wordWrap', 'w:overflowPunct', 'w:topLinePunct', 'w:autoSpaceDE', + 'w:autoSpaceDN', 'w:bidi', 'w:adjustRightInd', 'w:snapToGrid', + 'w:spacing', 'w:ind', 'w:contextualSpacing', 'w:mirrorIndents', + 'w:suppressOverlap', 'w:jc', 'w:textDirection', 'w:textAlignment', + 'w:textboxTightWrap', 'w:outlineLvl', 'w:divId', 'w:cnfStyle', + 'w:rPr', 'w:sectPr', 'w:pPrChange' + ) + pStyle = ZeroOrOne('w:pStyle') + numPr = ZeroOrOne('w:numPr', successors=__child_sequence__[7:]) + jc = ZeroOrOne('w:jc', successors=__child_sequence__[27:]) + sectPr = ZeroOrOne('w:sectPr', successors=('w:pPrChange',)) + + def _insert_pStyle(self, pStyle): + self.insert(0, pStyle) + return pStyle + + @property + def alignment(self): + """ + The value of the ```` child element or |None| if not present. + """ + jc = self.jc + if jc is None: + return None + return jc.val + + @alignment.setter + def alignment(self, value): + if value is None: + self._remove_jc() + return + jc = self.get_or_add_jc() + jc.val = value + + @property + def style(self): + """ + String contained in child, or None if that element is not + present. + """ + pStyle = self.pStyle + if pStyle is None: + return None + return pStyle.val + + @style.setter + def style(self, style): + """ + Set val attribute of child element to *style*, adding a + new element if necessary. If *style* is |None|, remove the + element if present. + """ + if style is None: + self._remove_pStyle() + return + pStyle = self.get_or_add_pStyle() + pStyle.val = style diff --git a/tests/test_text.py b/tests/test_text.py index eba0cc222..f25533c7f 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -10,7 +10,7 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_UNDERLINE from docx.oxml.ns import qn -from docx.oxml.text import CT_P +from docx.oxml.text.paragraph import CT_P from docx.oxml.text.run import CT_R from docx.parts.document import InlineShapes from docx.shape import InlineShape From cbd587ecdbd1d52c404875a2e24460b6cd12a2c0 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 15:36:56 -0800 Subject: [PATCH 260/809] reorg: extract tests.text.test_run module --- tests/test_text.py | 352 +-------------------------------------- tests/text/__init__.py | 0 tests/text/test_run.py | 367 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 351 deletions(-) create mode 100644 tests/text/__init__.py create mode 100644 tests/text/test_run.py diff --git a/tests/test_text.py b/tests/test_text.py index f25533c7f..b91cb4382 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -8,12 +8,10 @@ absolute_import, division, print_function, unicode_literals ) -from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_UNDERLINE +from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml.ns import qn from docx.oxml.text.paragraph import CT_P from docx.oxml.text.run import CT_R -from docx.parts.document import InlineShapes -from docx.shape import InlineShape from docx.text import Paragraph, Run import pytest @@ -230,351 +228,3 @@ def runs_(self, request): run_ = instance_mock(request, Run, name='run_') run_2_ = instance_mock(request, Run, name='run_2_') return run_, run_2_ - - -class DescribeRun(object): - - def it_knows_its_bool_prop_states(self, bool_prop_get_fixture): - run, prop_name, expected_state = bool_prop_get_fixture - assert getattr(run, prop_name) == expected_state - - def it_can_change_its_bool_prop_settings(self, bool_prop_set_fixture): - run, prop_name, value, expected_xml = bool_prop_set_fixture - setattr(run, prop_name, value) - assert run._r.xml == expected_xml - - def it_knows_its_character_style(self, style_get_fixture): - run, expected_style = style_get_fixture - assert run.style == expected_style - - def it_can_change_its_character_style(self, style_set_fixture): - run, style, expected_xml = style_set_fixture - run.style = style - assert run._r.xml == expected_xml - - def it_knows_its_underline_type(self, underline_get_fixture): - run, expected_value = underline_get_fixture - assert run.underline is expected_value - - def it_can_change_its_underline_type(self, underline_set_fixture): - run, underline, expected_xml = underline_set_fixture - run.underline = underline - assert run._r.xml == expected_xml - - def it_raises_on_assign_invalid_underline_type( - self, underline_raise_fixture): - run, underline = underline_raise_fixture - with pytest.raises(ValueError): - run.underline = underline - - def it_can_add_text(self, add_text_fixture): - run, text_str, expected_xml, Text_ = add_text_fixture - _text = run.add_text(text_str) - assert run._r.xml == expected_xml - assert _text is Text_.return_value - - def it_can_add_a_break(self, add_break_fixture): - run, break_type, expected_xml = add_break_fixture - run.add_break(break_type) - assert run._r.xml == expected_xml - - def it_can_add_a_tab(self, add_tab_fixture): - run, expected_xml = add_tab_fixture - run.add_tab() - assert run._r.xml == expected_xml - - def it_can_add_a_picture(self, add_picture_fixture): - (run, image_descriptor_, width, height, inline_shapes_, - expected_width, expected_height, picture_) = add_picture_fixture - picture = run.add_picture(image_descriptor_, width, height) - inline_shapes_.add_picture.assert_called_once_with( - image_descriptor_, run - ) - assert picture is picture_ - assert picture.width == expected_width - assert picture.height == expected_height - - def it_can_remove_its_content_but_keep_formatting(self, clear_fixture): - run, expected_xml = clear_fixture - _run = run.clear() - assert run._r.xml == expected_xml - assert _run is run - - def it_knows_the_text_it_contains(self, text_get_fixture): - run, expected_text = text_get_fixture - assert run.text == expected_text - - def it_can_replace_the_text_it_contains(self, text_set_fixture): - run, text, expected_xml = text_set_fixture - run.text = text - assert run._r.xml == expected_xml - - # fixtures ------------------------------------------------------- - - @pytest.fixture(params=[ - (WD_BREAK.LINE, 'w:r/w:br'), - (WD_BREAK.PAGE, 'w:r/w:br{w:type=page}'), - (WD_BREAK.COLUMN, 'w:r/w:br{w:type=column}'), - (WD_BREAK.LINE_CLEAR_LEFT, - 'w:r/w:br{w:type=textWrapping, w:clear=left}'), - (WD_BREAK.LINE_CLEAR_RIGHT, - 'w:r/w:br{w:type=textWrapping, w:clear=right}'), - (WD_BREAK.LINE_CLEAR_ALL, - 'w:r/w:br{w:type=textWrapping, w:clear=all}'), - ]) - def add_break_fixture(self, request): - break_type, expected_cxml = request.param - run = Run(element('w:r'), None) - expected_xml = xml(expected_cxml) - return run, break_type, expected_xml - - @pytest.fixture(params=[ - (None, None, 200, 100), - (1000, 500, 1000, 500), - (2000, None, 2000, 1000), - (None, 2000, 4000, 2000), - ]) - def add_picture_fixture( - self, request, paragraph_, inline_shapes_, picture_): - width, height, expected_width, expected_height = request.param - paragraph_.part.inline_shapes = inline_shapes_ - run = Run(None, paragraph_) - image_descriptor_ = 'image_descriptor_' - picture_.width, picture_.height = 200, 100 - return ( - run, image_descriptor_, width, height, inline_shapes_, - expected_width, expected_height, picture_ - ) - - @pytest.fixture(params=[ - ('w:r/w:t"foo"', 'w:r/(w:t"foo", w:tab)'), - ]) - def add_tab_fixture(self, request): - r_cxml, expected_cxml = request.param - run = Run(element(r_cxml), None) - expected_xml = xml(expected_cxml) - return run, expected_xml - - @pytest.fixture(params=[ - ('w:r', 'foo', 'w:r/w:t"foo"'), - ('w:r/w:t"foo"', 'bar', 'w:r/(w:t"foo", w:t"bar")'), - ('w:r', 'fo ', 'w:r/w:t{xml:space=preserve}"fo "'), - ('w:r', 'f o', 'w:r/w:t"f o"'), - ]) - def add_text_fixture(self, request, Text_): - r_cxml, text, expected_cxml = request.param - run = Run(element(r_cxml), None) - expected_xml = xml(expected_cxml) - return run, text, expected_xml, Text_ - - @pytest.fixture(params=[ - ('w:r/w:rPr', 'all_caps', None), - ('w:r/w:rPr/w:caps', 'all_caps', True), - ('w:r/w:rPr/w:caps{w:val=on}', 'all_caps', True), - ('w:r/w:rPr/w:caps{w:val=off}', 'all_caps', False), - ('w:r/w:rPr/w:b{w:val=1}', 'bold', True), - ('w:r/w:rPr/w:i{w:val=0}', 'italic', False), - ('w:r/w:rPr/w:cs{w:val=true}', 'complex_script', True), - ('w:r/w:rPr/w:bCs{w:val=false}', 'cs_bold', False), - ('w:r/w:rPr/w:iCs{w:val=on}', 'cs_italic', True), - ('w:r/w:rPr/w:dstrike{w:val=off}', 'double_strike', False), - ('w:r/w:rPr/w:emboss{w:val=1}', 'emboss', True), - ('w:r/w:rPr/w:vanish{w:val=0}', 'hidden', False), - ('w:r/w:rPr/w:i{w:val=true}', 'italic', True), - ('w:r/w:rPr/w:imprint{w:val=false}', 'imprint', False), - ('w:r/w:rPr/w:oMath{w:val=on}', 'math', True), - ('w:r/w:rPr/w:noProof{w:val=off}', 'no_proof', False), - ('w:r/w:rPr/w:outline{w:val=1}', 'outline', True), - ('w:r/w:rPr/w:rtl{w:val=0}', 'rtl', False), - ('w:r/w:rPr/w:shadow{w:val=true}', 'shadow', True), - ('w:r/w:rPr/w:smallCaps{w:val=false}', 'small_caps', False), - ('w:r/w:rPr/w:snapToGrid{w:val=on}', 'snap_to_grid', True), - ('w:r/w:rPr/w:specVanish{w:val=off}', 'spec_vanish', False), - ('w:r/w:rPr/w:strike{w:val=1}', 'strike', True), - ('w:r/w:rPr/w:webHidden{w:val=0}', 'web_hidden', False), - ]) - def bool_prop_get_fixture(self, request): - r_cxml, bool_prop_name, expected_value = request.param - run = Run(element(r_cxml), None) - return run, bool_prop_name, expected_value - - @pytest.fixture(params=[ - # nothing to True, False, and None --------------------------- - ('w:r', 'all_caps', True, - 'w:r/w:rPr/w:caps'), - ('w:r', 'bold', False, - 'w:r/w:rPr/w:b{w:val=0}'), - ('w:r', 'italic', None, - 'w:r/w:rPr'), - # default to True, False, and None --------------------------- - ('w:r/w:rPr/w:cs', 'complex_script', True, - 'w:r/w:rPr/w:cs'), - ('w:r/w:rPr/w:bCs', 'cs_bold', False, - 'w:r/w:rPr/w:bCs{w:val=0}'), - ('w:r/w:rPr/w:iCs', 'cs_italic', None, - 'w:r/w:rPr'), - # True to True, False, and None ------------------------------ - ('w:r/w:rPr/w:dstrike{w:val=1}', 'double_strike', True, - 'w:r/w:rPr/w:dstrike'), - ('w:r/w:rPr/w:emboss{w:val=on}', 'emboss', False, - 'w:r/w:rPr/w:emboss{w:val=0}'), - ('w:r/w:rPr/w:vanish{w:val=1}', 'hidden', None, - 'w:r/w:rPr'), - # False to True, False, and None ----------------------------- - ('w:r/w:rPr/w:i{w:val=false}', 'italic', True, - 'w:r/w:rPr/w:i'), - ('w:r/w:rPr/w:imprint{w:val=0}', 'imprint', False, - 'w:r/w:rPr/w:imprint{w:val=0}'), - ('w:r/w:rPr/w:oMath{w:val=off}', 'math', None, - 'w:r/w:rPr'), - # random mix ------------------------------------------------- - ('w:r/w:rPr/w:noProof{w:val=1}', 'no_proof', False, - 'w:r/w:rPr/w:noProof{w:val=0}'), - ('w:r/w:rPr', 'outline', True, - 'w:r/w:rPr/w:outline'), - ('w:r/w:rPr/w:rtl{w:val=true}', 'rtl', False, - 'w:r/w:rPr/w:rtl{w:val=0}'), - ('w:r/w:rPr/w:shadow{w:val=on}', 'shadow', True, - 'w:r/w:rPr/w:shadow'), - ('w:r/w:rPr/w:smallCaps', 'small_caps', False, - 'w:r/w:rPr/w:smallCaps{w:val=0}'), - ('w:r/w:rPr/w:snapToGrid', 'snap_to_grid', True, - 'w:r/w:rPr/w:snapToGrid'), - ('w:r/w:rPr/w:specVanish', 'spec_vanish', None, - 'w:r/w:rPr'), - ('w:r/w:rPr/w:strike{w:val=foo}', 'strike', True, - 'w:r/w:rPr/w:strike'), - ('w:r/w:rPr/w:webHidden', 'web_hidden', False, - 'w:r/w:rPr/w:webHidden{w:val=0}'), - ]) - def bool_prop_set_fixture(self, request): - initial_r_cxml, bool_prop_name, value, expected_cxml = request.param - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, bool_prop_name, value, expected_xml - - @pytest.fixture(params=[ - ('w:r', 'w:r'), - ('w:r/w:t"foo"', 'w:r'), - ('w:r/w:br', 'w:r'), - ('w:r/w:rPr', 'w:r/w:rPr'), - ('w:r/(w:rPr, w:t"foo")', 'w:r/w:rPr'), - ('w:r/(w:rPr/(w:b, w:i), w:t"foo", w:cr, w:t"bar")', - 'w:r/w:rPr/(w:b, w:i)'), - ]) - def clear_fixture(self, request): - initial_r_cxml, expected_cxml = request.param - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, expected_xml - - @pytest.fixture(params=[ - ('w:r', None), - ('w:r/w:rPr/w:rStyle{w:val=Foobar}', 'Foobar'), - ]) - def style_get_fixture(self, request): - r_cxml, expected_style = request.param - run = Run(element(r_cxml), None) - return run, expected_style - - @pytest.fixture(params=[ - ('w:r', None, - 'w:r/w:rPr'), - ('w:r', 'Foo', - 'w:r/w:rPr/w:rStyle{w:val=Foo}'), - ('w:r/w:rPr/w:rStyle{w:val=Foo}', None, - 'w:r/w:rPr'), - ('w:r/w:rPr/w:rStyle{w:val=Foo}', 'Bar', - 'w:r/w:rPr/w:rStyle{w:val=Bar}'), - ]) - def style_set_fixture(self, request): - initial_r_cxml, new_style, expected_cxml = request.param - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, new_style, expected_xml - - @pytest.fixture(params=[ - ('w:r', ''), - ('w:r/w:t"foobar"', 'foobar'), - ('w:r/(w:t"abc", w:tab, w:t"def", w:cr)', 'abc\tdef\n'), - ('w:r/(w:br{w:type=page}, w:t"abc", w:t"def", w:tab)', '\nabcdef\t'), - ]) - def text_get_fixture(self, request): - r_cxml, expected_text = request.param - run = Run(element(r_cxml), None) - return run, expected_text - - @pytest.fixture(params=[ - ('abc def', 'w:r/w:t"abc def"'), - ('abc\tdef', 'w:r/(w:t"abc", w:tab, w:t"def")'), - ('abc\ndef', 'w:r/(w:t"abc", w:br, w:t"def")'), - ('abc\rdef', 'w:r/(w:t"abc", w:br, w:t"def")'), - ]) - def text_set_fixture(self, request): - new_text, expected_cxml = request.param - initial_r_cxml = 'w:r/w:t"should get deleted"' - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, new_text, expected_xml - - @pytest.fixture(params=[ - ('w:r', None), - ('w:r/w:rPr/w:u', None), - ('w:r/w:rPr/w:u{w:val=single}', True), - ('w:r/w:rPr/w:u{w:val=none}', False), - ('w:r/w:rPr/w:u{w:val=double}', WD_UNDERLINE.DOUBLE), - ('w:r/w:rPr/w:u{w:val=wave}', WD_UNDERLINE.WAVY), - ]) - def underline_get_fixture(self, request): - r_cxml, expected_underline = request.param - run = Run(element(r_cxml), None) - return run, expected_underline - - @pytest.fixture(params=[ - ('w:r', True, 'w:r/w:rPr/w:u{w:val=single}'), - ('w:r', False, 'w:r/w:rPr/w:u{w:val=none}'), - ('w:r', None, 'w:r/w:rPr'), - ('w:r', WD_UNDERLINE.SINGLE, 'w:r/w:rPr/w:u{w:val=single}'), - ('w:r', WD_UNDERLINE.THICK, 'w:r/w:rPr/w:u{w:val=thick}'), - ('w:r/w:rPr/w:u{w:val=single}', True, - 'w:r/w:rPr/w:u{w:val=single}'), - ('w:r/w:rPr/w:u{w:val=single}', False, - 'w:r/w:rPr/w:u{w:val=none}'), - ('w:r/w:rPr/w:u{w:val=single}', None, - 'w:r/w:rPr'), - ('w:r/w:rPr/w:u{w:val=single}', WD_UNDERLINE.SINGLE, - 'w:r/w:rPr/w:u{w:val=single}'), - ('w:r/w:rPr/w:u{w:val=single}', WD_UNDERLINE.DOTTED, - 'w:r/w:rPr/w:u{w:val=dotted}'), - ]) - def underline_set_fixture(self, request): - initial_r_cxml, new_underline, expected_cxml = request.param - run = Run(element(initial_r_cxml), None) - expected_xml = xml(expected_cxml) - return run, new_underline, expected_xml - - @pytest.fixture(params=['foobar', 42, 'single']) - def underline_raise_fixture(self, request): - invalid_underline_setting = request.param - run = Run(element('w:r/w:rPr'), None) - return run, invalid_underline_setting - - # fixture components --------------------------------------------- - - @pytest.fixture - def inline_shapes_(self, request, picture_): - inline_shapes_ = instance_mock(request, InlineShapes) - inline_shapes_.add_picture.return_value = picture_ - return inline_shapes_ - - @pytest.fixture - def paragraph_(self, request): - return instance_mock(request, Paragraph) - - @pytest.fixture - def picture_(self, request): - return instance_mock(request, InlineShape) - - @pytest.fixture - def Text_(self, request): - return class_mock(request, 'docx.text.Text') diff --git a/tests/text/__init__.py b/tests/text/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/text/test_run.py b/tests/text/test_run.py new file mode 100644 index 000000000..5803e7353 --- /dev/null +++ b/tests/text/test_run.py @@ -0,0 +1,367 @@ +# encoding: utf-8 + +""" +Test suite for the docx.text.run module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from docx.enum.text import WD_BREAK, WD_UNDERLINE +from docx.parts.document import InlineShapes +from docx.shape import InlineShape +from docx.text import Paragraph, Run + +import pytest + +from ..unitutil.cxml import element, xml +from ..unitutil.mock import class_mock, instance_mock + + +class DescribeRun(object): + + def it_knows_its_bool_prop_states(self, bool_prop_get_fixture): + run, prop_name, expected_state = bool_prop_get_fixture + assert getattr(run, prop_name) == expected_state + + def it_can_change_its_bool_prop_settings(self, bool_prop_set_fixture): + run, prop_name, value, expected_xml = bool_prop_set_fixture + setattr(run, prop_name, value) + assert run._r.xml == expected_xml + + def it_knows_its_character_style(self, style_get_fixture): + run, expected_style = style_get_fixture + assert run.style == expected_style + + def it_can_change_its_character_style(self, style_set_fixture): + run, style, expected_xml = style_set_fixture + run.style = style + assert run._r.xml == expected_xml + + def it_knows_its_underline_type(self, underline_get_fixture): + run, expected_value = underline_get_fixture + assert run.underline is expected_value + + def it_can_change_its_underline_type(self, underline_set_fixture): + run, underline, expected_xml = underline_set_fixture + run.underline = underline + assert run._r.xml == expected_xml + + def it_raises_on_assign_invalid_underline_type( + self, underline_raise_fixture): + run, underline = underline_raise_fixture + with pytest.raises(ValueError): + run.underline = underline + + def it_can_add_text(self, add_text_fixture): + run, text_str, expected_xml, Text_ = add_text_fixture + _text = run.add_text(text_str) + assert run._r.xml == expected_xml + assert _text is Text_.return_value + + def it_can_add_a_break(self, add_break_fixture): + run, break_type, expected_xml = add_break_fixture + run.add_break(break_type) + assert run._r.xml == expected_xml + + def it_can_add_a_tab(self, add_tab_fixture): + run, expected_xml = add_tab_fixture + run.add_tab() + assert run._r.xml == expected_xml + + def it_can_add_a_picture(self, add_picture_fixture): + (run, image_descriptor_, width, height, inline_shapes_, + expected_width, expected_height, picture_) = add_picture_fixture + picture = run.add_picture(image_descriptor_, width, height) + inline_shapes_.add_picture.assert_called_once_with( + image_descriptor_, run + ) + assert picture is picture_ + assert picture.width == expected_width + assert picture.height == expected_height + + def it_can_remove_its_content_but_keep_formatting(self, clear_fixture): + run, expected_xml = clear_fixture + _run = run.clear() + assert run._r.xml == expected_xml + assert _run is run + + def it_knows_the_text_it_contains(self, text_get_fixture): + run, expected_text = text_get_fixture + assert run.text == expected_text + + def it_can_replace_the_text_it_contains(self, text_set_fixture): + run, text, expected_xml = text_set_fixture + run.text = text + assert run._r.xml == expected_xml + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + (WD_BREAK.LINE, 'w:r/w:br'), + (WD_BREAK.PAGE, 'w:r/w:br{w:type=page}'), + (WD_BREAK.COLUMN, 'w:r/w:br{w:type=column}'), + (WD_BREAK.LINE_CLEAR_LEFT, + 'w:r/w:br{w:type=textWrapping, w:clear=left}'), + (WD_BREAK.LINE_CLEAR_RIGHT, + 'w:r/w:br{w:type=textWrapping, w:clear=right}'), + (WD_BREAK.LINE_CLEAR_ALL, + 'w:r/w:br{w:type=textWrapping, w:clear=all}'), + ]) + def add_break_fixture(self, request): + break_type, expected_cxml = request.param + run = Run(element('w:r'), None) + expected_xml = xml(expected_cxml) + return run, break_type, expected_xml + + @pytest.fixture(params=[ + (None, None, 200, 100), + (1000, 500, 1000, 500), + (2000, None, 2000, 1000), + (None, 2000, 4000, 2000), + ]) + def add_picture_fixture( + self, request, paragraph_, inline_shapes_, picture_): + width, height, expected_width, expected_height = request.param + paragraph_.part.inline_shapes = inline_shapes_ + run = Run(None, paragraph_) + image_descriptor_ = 'image_descriptor_' + picture_.width, picture_.height = 200, 100 + return ( + run, image_descriptor_, width, height, inline_shapes_, + expected_width, expected_height, picture_ + ) + + @pytest.fixture(params=[ + ('w:r/w:t"foo"', 'w:r/(w:t"foo", w:tab)'), + ]) + def add_tab_fixture(self, request): + r_cxml, expected_cxml = request.param + run = Run(element(r_cxml), None) + expected_xml = xml(expected_cxml) + return run, expected_xml + + @pytest.fixture(params=[ + ('w:r', 'foo', 'w:r/w:t"foo"'), + ('w:r/w:t"foo"', 'bar', 'w:r/(w:t"foo", w:t"bar")'), + ('w:r', 'fo ', 'w:r/w:t{xml:space=preserve}"fo "'), + ('w:r', 'f o', 'w:r/w:t"f o"'), + ]) + def add_text_fixture(self, request, Text_): + r_cxml, text, expected_cxml = request.param + run = Run(element(r_cxml), None) + expected_xml = xml(expected_cxml) + return run, text, expected_xml, Text_ + + @pytest.fixture(params=[ + ('w:r/w:rPr', 'all_caps', None), + ('w:r/w:rPr/w:caps', 'all_caps', True), + ('w:r/w:rPr/w:caps{w:val=on}', 'all_caps', True), + ('w:r/w:rPr/w:caps{w:val=off}', 'all_caps', False), + ('w:r/w:rPr/w:b{w:val=1}', 'bold', True), + ('w:r/w:rPr/w:i{w:val=0}', 'italic', False), + ('w:r/w:rPr/w:cs{w:val=true}', 'complex_script', True), + ('w:r/w:rPr/w:bCs{w:val=false}', 'cs_bold', False), + ('w:r/w:rPr/w:iCs{w:val=on}', 'cs_italic', True), + ('w:r/w:rPr/w:dstrike{w:val=off}', 'double_strike', False), + ('w:r/w:rPr/w:emboss{w:val=1}', 'emboss', True), + ('w:r/w:rPr/w:vanish{w:val=0}', 'hidden', False), + ('w:r/w:rPr/w:i{w:val=true}', 'italic', True), + ('w:r/w:rPr/w:imprint{w:val=false}', 'imprint', False), + ('w:r/w:rPr/w:oMath{w:val=on}', 'math', True), + ('w:r/w:rPr/w:noProof{w:val=off}', 'no_proof', False), + ('w:r/w:rPr/w:outline{w:val=1}', 'outline', True), + ('w:r/w:rPr/w:rtl{w:val=0}', 'rtl', False), + ('w:r/w:rPr/w:shadow{w:val=true}', 'shadow', True), + ('w:r/w:rPr/w:smallCaps{w:val=false}', 'small_caps', False), + ('w:r/w:rPr/w:snapToGrid{w:val=on}', 'snap_to_grid', True), + ('w:r/w:rPr/w:specVanish{w:val=off}', 'spec_vanish', False), + ('w:r/w:rPr/w:strike{w:val=1}', 'strike', True), + ('w:r/w:rPr/w:webHidden{w:val=0}', 'web_hidden', False), + ]) + def bool_prop_get_fixture(self, request): + r_cxml, bool_prop_name, expected_value = request.param + run = Run(element(r_cxml), None) + return run, bool_prop_name, expected_value + + @pytest.fixture(params=[ + # nothing to True, False, and None --------------------------- + ('w:r', 'all_caps', True, + 'w:r/w:rPr/w:caps'), + ('w:r', 'bold', False, + 'w:r/w:rPr/w:b{w:val=0}'), + ('w:r', 'italic', None, + 'w:r/w:rPr'), + # default to True, False, and None --------------------------- + ('w:r/w:rPr/w:cs', 'complex_script', True, + 'w:r/w:rPr/w:cs'), + ('w:r/w:rPr/w:bCs', 'cs_bold', False, + 'w:r/w:rPr/w:bCs{w:val=0}'), + ('w:r/w:rPr/w:iCs', 'cs_italic', None, + 'w:r/w:rPr'), + # True to True, False, and None ------------------------------ + ('w:r/w:rPr/w:dstrike{w:val=1}', 'double_strike', True, + 'w:r/w:rPr/w:dstrike'), + ('w:r/w:rPr/w:emboss{w:val=on}', 'emboss', False, + 'w:r/w:rPr/w:emboss{w:val=0}'), + ('w:r/w:rPr/w:vanish{w:val=1}', 'hidden', None, + 'w:r/w:rPr'), + # False to True, False, and None ----------------------------- + ('w:r/w:rPr/w:i{w:val=false}', 'italic', True, + 'w:r/w:rPr/w:i'), + ('w:r/w:rPr/w:imprint{w:val=0}', 'imprint', False, + 'w:r/w:rPr/w:imprint{w:val=0}'), + ('w:r/w:rPr/w:oMath{w:val=off}', 'math', None, + 'w:r/w:rPr'), + # random mix ------------------------------------------------- + ('w:r/w:rPr/w:noProof{w:val=1}', 'no_proof', False, + 'w:r/w:rPr/w:noProof{w:val=0}'), + ('w:r/w:rPr', 'outline', True, + 'w:r/w:rPr/w:outline'), + ('w:r/w:rPr/w:rtl{w:val=true}', 'rtl', False, + 'w:r/w:rPr/w:rtl{w:val=0}'), + ('w:r/w:rPr/w:shadow{w:val=on}', 'shadow', True, + 'w:r/w:rPr/w:shadow'), + ('w:r/w:rPr/w:smallCaps', 'small_caps', False, + 'w:r/w:rPr/w:smallCaps{w:val=0}'), + ('w:r/w:rPr/w:snapToGrid', 'snap_to_grid', True, + 'w:r/w:rPr/w:snapToGrid'), + ('w:r/w:rPr/w:specVanish', 'spec_vanish', None, + 'w:r/w:rPr'), + ('w:r/w:rPr/w:strike{w:val=foo}', 'strike', True, + 'w:r/w:rPr/w:strike'), + ('w:r/w:rPr/w:webHidden', 'web_hidden', False, + 'w:r/w:rPr/w:webHidden{w:val=0}'), + ]) + def bool_prop_set_fixture(self, request): + initial_r_cxml, bool_prop_name, value, expected_cxml = request.param + run = Run(element(initial_r_cxml), None) + expected_xml = xml(expected_cxml) + return run, bool_prop_name, value, expected_xml + + @pytest.fixture(params=[ + ('w:r', 'w:r'), + ('w:r/w:t"foo"', 'w:r'), + ('w:r/w:br', 'w:r'), + ('w:r/w:rPr', 'w:r/w:rPr'), + ('w:r/(w:rPr, w:t"foo")', 'w:r/w:rPr'), + ('w:r/(w:rPr/(w:b, w:i), w:t"foo", w:cr, w:t"bar")', + 'w:r/w:rPr/(w:b, w:i)'), + ]) + def clear_fixture(self, request): + initial_r_cxml, expected_cxml = request.param + run = Run(element(initial_r_cxml), None) + expected_xml = xml(expected_cxml) + return run, expected_xml + + @pytest.fixture(params=[ + ('w:r', None), + ('w:r/w:rPr/w:rStyle{w:val=Foobar}', 'Foobar'), + ]) + def style_get_fixture(self, request): + r_cxml, expected_style = request.param + run = Run(element(r_cxml), None) + return run, expected_style + + @pytest.fixture(params=[ + ('w:r', None, + 'w:r/w:rPr'), + ('w:r', 'Foo', + 'w:r/w:rPr/w:rStyle{w:val=Foo}'), + ('w:r/w:rPr/w:rStyle{w:val=Foo}', None, + 'w:r/w:rPr'), + ('w:r/w:rPr/w:rStyle{w:val=Foo}', 'Bar', + 'w:r/w:rPr/w:rStyle{w:val=Bar}'), + ]) + def style_set_fixture(self, request): + initial_r_cxml, new_style, expected_cxml = request.param + run = Run(element(initial_r_cxml), None) + expected_xml = xml(expected_cxml) + return run, new_style, expected_xml + + @pytest.fixture(params=[ + ('w:r', ''), + ('w:r/w:t"foobar"', 'foobar'), + ('w:r/(w:t"abc", w:tab, w:t"def", w:cr)', 'abc\tdef\n'), + ('w:r/(w:br{w:type=page}, w:t"abc", w:t"def", w:tab)', '\nabcdef\t'), + ]) + def text_get_fixture(self, request): + r_cxml, expected_text = request.param + run = Run(element(r_cxml), None) + return run, expected_text + + @pytest.fixture(params=[ + ('abc def', 'w:r/w:t"abc def"'), + ('abc\tdef', 'w:r/(w:t"abc", w:tab, w:t"def")'), + ('abc\ndef', 'w:r/(w:t"abc", w:br, w:t"def")'), + ('abc\rdef', 'w:r/(w:t"abc", w:br, w:t"def")'), + ]) + def text_set_fixture(self, request): + new_text, expected_cxml = request.param + initial_r_cxml = 'w:r/w:t"should get deleted"' + run = Run(element(initial_r_cxml), None) + expected_xml = xml(expected_cxml) + return run, new_text, expected_xml + + @pytest.fixture(params=[ + ('w:r', None), + ('w:r/w:rPr/w:u', None), + ('w:r/w:rPr/w:u{w:val=single}', True), + ('w:r/w:rPr/w:u{w:val=none}', False), + ('w:r/w:rPr/w:u{w:val=double}', WD_UNDERLINE.DOUBLE), + ('w:r/w:rPr/w:u{w:val=wave}', WD_UNDERLINE.WAVY), + ]) + def underline_get_fixture(self, request): + r_cxml, expected_underline = request.param + run = Run(element(r_cxml), None) + return run, expected_underline + + @pytest.fixture(params=[ + ('w:r', True, 'w:r/w:rPr/w:u{w:val=single}'), + ('w:r', False, 'w:r/w:rPr/w:u{w:val=none}'), + ('w:r', None, 'w:r/w:rPr'), + ('w:r', WD_UNDERLINE.SINGLE, 'w:r/w:rPr/w:u{w:val=single}'), + ('w:r', WD_UNDERLINE.THICK, 'w:r/w:rPr/w:u{w:val=thick}'), + ('w:r/w:rPr/w:u{w:val=single}', True, + 'w:r/w:rPr/w:u{w:val=single}'), + ('w:r/w:rPr/w:u{w:val=single}', False, + 'w:r/w:rPr/w:u{w:val=none}'), + ('w:r/w:rPr/w:u{w:val=single}', None, + 'w:r/w:rPr'), + ('w:r/w:rPr/w:u{w:val=single}', WD_UNDERLINE.SINGLE, + 'w:r/w:rPr/w:u{w:val=single}'), + ('w:r/w:rPr/w:u{w:val=single}', WD_UNDERLINE.DOTTED, + 'w:r/w:rPr/w:u{w:val=dotted}'), + ]) + def underline_set_fixture(self, request): + initial_r_cxml, new_underline, expected_cxml = request.param + run = Run(element(initial_r_cxml), None) + expected_xml = xml(expected_cxml) + return run, new_underline, expected_xml + + @pytest.fixture(params=['foobar', 42, 'single']) + def underline_raise_fixture(self, request): + invalid_underline_setting = request.param + run = Run(element('w:r/w:rPr'), None) + return run, invalid_underline_setting + + # fixture components --------------------------------------------- + + @pytest.fixture + def inline_shapes_(self, request, picture_): + inline_shapes_ = instance_mock(request, InlineShapes) + inline_shapes_.add_picture.return_value = picture_ + return inline_shapes_ + + @pytest.fixture + def paragraph_(self, request): + return instance_mock(request, Paragraph) + + @pytest.fixture + def picture_(self, request): + return instance_mock(request, InlineShape) + + @pytest.fixture + def Text_(self, request): + return class_mock(request, 'docx.text.Text') From cd918489db429f665f94de92eaabbd2e5cfe3908 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 16:04:06 -0800 Subject: [PATCH 261/809] reorg: extract tests.text.test_paragraph module --- tests/{test_text.py => text/test_paragraph.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename tests/{test_text.py => text/test_paragraph.py} (98%) diff --git a/tests/test_text.py b/tests/text/test_paragraph.py similarity index 98% rename from tests/test_text.py rename to tests/text/test_paragraph.py index b91cb4382..434b900a0 100644 --- a/tests/test_text.py +++ b/tests/text/test_paragraph.py @@ -16,8 +16,8 @@ import pytest -from .unitutil.cxml import element, xml -from .unitutil.mock import call, class_mock, instance_mock +from ..unitutil.cxml import element, xml +from ..unitutil.mock import call, class_mock, instance_mock class DescribeParagraph(object): From c1215cfe5c07b99572dcb352585c34a1efb66f63 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 15:37:23 -0800 Subject: [PATCH 262/809] reorg: extract docx.text.run module --- docx/text/__init__.py | 373 +--------------------------------------- docx/text/run.py | 381 +++++++++++++++++++++++++++++++++++++++++ tests/text/test_run.py | 2 +- 3 files changed, 383 insertions(+), 373 deletions(-) create mode 100644 docx/text/run.py diff --git a/docx/text/__init__.py b/docx/text/__init__.py index 21131c3d0..1351a76b4 100644 --- a/docx/text/__init__.py +++ b/docx/text/__init__.py @@ -6,55 +6,10 @@ from __future__ import absolute_import, print_function, unicode_literals -from ..enum.text import WD_BREAK +from .run import Run from ..shared import Parented -def boolproperty(f): - """ - @boolproperty decorator. Decorated method must return the XML element - name of the boolean property element occuring under rPr. Causes - a read/write tri-state property to be added to the class having the name - of the decorated function. - """ - def _get_prop_value(parent, attr_name): - return getattr(parent, attr_name) - - def _remove_prop(parent, attr_name): - remove_method_name = '_remove_%s' % attr_name - remove_method = getattr(parent, remove_method_name) - remove_method() - - def _add_prop(parent, attr_name): - add_method_name = '_add_%s' % attr_name - add_method = getattr(parent, add_method_name) - return add_method() - - def getter(obj): - r, attr_name = obj._r, f(obj) - if r.rPr is None: - return None - prop_value = _get_prop_value(r.rPr, attr_name) - if prop_value is None: - return None - return prop_value.val - - def setter(obj, value): - if value not in (True, False, None): - raise ValueError( - "assigned value must be True, False, or None, got '%s'" - % value - ) - r, attr_name = obj._r, f(obj) - rPr = r.get_or_add_rPr() - _remove_prop(rPr, attr_name) - if value is not None: - elm = _add_prop(rPr, attr_name) - elm.val = value - - return property(getter, setter, doc=f.__doc__) - - class Paragraph(Parented): """ Proxy object wrapping ```` element. @@ -161,329 +116,3 @@ def text(self): def text(self, text): self.clear() self.add_run(text) - - -class Run(Parented): - """ - Proxy object wrapping ```` element. Several of the properties on Run - take a tri-state value, |True|, |False|, or |None|. |True| and |False| - correspond to on and off respectively. |None| indicates the property is - 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 = r - - def add_break(self, break_type=WD_BREAK.LINE): - """ - Add a break element of *break_type* to this run. *break_type* can - take the values `WD_BREAK.LINE`, `WD_BREAK.PAGE`, and - `WD_BREAK.COLUMN` where `WD_BREAK` is imported from `docx.enum.text`. - *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'), - }[break_type] - br = self._r.add_br() - if type_ is not None: - br.type = type_ - if clear is not None: - br.clear = clear - - def add_picture(self, image_path_or_stream, width=None, height=None): - """ - Return an |InlineShape| instance containing the image identified by - *image_path_or_stream*, added to the end of this run. - *image_path_or_stream* can be a path (a string) or a file-like object - containing a binary image. If neither width nor height is specified, - the picture appears at its native size. If only one is specified, it - is used to compute a scaling factor that is then applied to the - unspecified dimension, preserving the aspect ratio of the image. The - native size of the picture is calculated using the dots-per-inch - (dpi) value specified in the image file, defaulting to 72 dpi if no - value is specified, as is often the case. - """ - inline_shapes = self.part.inline_shapes - picture = inline_shapes.add_picture(image_path_or_stream, self) - - # scale picture dimensions if width and/or height provided - if width is not None or height is not None: - native_width, native_height = picture.width, picture.height - if width is None: - scaling_factor = float(height) / float(native_height) - width = int(round(native_width * scaling_factor)) - elif height is None: - scaling_factor = float(width) / float(native_width) - height = int(round(native_height * scaling_factor)) - # set picture to scaled dimensions - picture.width = width - picture.height = height - - return picture - - def add_tab(self): - """ - Add a ```` element at the end of the run, which Word - interprets as a tab character. - """ - self._r._add_tab() - - def add_text(self, text): - """ - Returns a newly appended |Text| object (corresponding to a new - ```` child element) to the run, containing *text*. Compare with - the possibly more friendly approach of assigning text to the - :attr:`Run.text` property. - """ - t = self._r.add_t(text) - return Text(t) - - @boolproperty - def all_caps(self): - """ - Read/write. Causes the text of the run to appear in capital letters. - """ - return 'caps' - - @boolproperty - def bold(self): - """ - Read/write. Causes the text of the run to appear in bold. - """ - return 'b' - - def clear(self): - """ - Return reference to this run after removing all its content. All run - formatting is preserved. - """ - self._r.clear_content() - return self - - @boolproperty - def complex_script(self): - """ - Read/write tri-state value. When |True|, causes the characters in the - run to be treated as complex script regardless of their Unicode - values. - """ - return 'cs' - - @boolproperty - def cs_bold(self): - """ - Read/write tri-state value. When |True|, causes the complex script - characters in the run to be displayed in bold typeface. - """ - return 'bCs' - - @boolproperty - def cs_italic(self): - """ - Read/write tri-state value. When |True|, causes the complex script - characters in the run to be displayed in italic typeface. - """ - return 'iCs' - - @boolproperty - def double_strike(self): - """ - Read/write tri-state value. When |True|, causes the text in the run - to appear with double strikethrough. - """ - return 'dstrike' - - @boolproperty - def emboss(self): - """ - Read/write tri-state value. When |True|, causes the text in the run - to appear as if raised off the page in relief. - """ - return 'emboss' - - @boolproperty - def hidden(self): - """ - Read/write tri-state value. When |True|, causes the text in the run - to be hidden from display, unless applications settings force hidden - text to be shown. - """ - return 'vanish' - - @boolproperty - def italic(self): - """ - Read/write tri-state value. When |True|, causes the text of the run - to appear in italics. - """ - return 'i' - - @boolproperty - def imprint(self): - """ - Read/write tri-state value. When |True|, causes the text in the run - to appear as if pressed into the page. - """ - return 'imprint' - - @boolproperty - def math(self): - """ - Read/write tri-state value. When |True|, specifies this run contains - WML that should be handled as though it was Office Open XML Math. - """ - return 'oMath' - - @boolproperty - def no_proof(self): - """ - Read/write tri-state value. When |True|, specifies that the contents - of this run should not report any errors when the document is scanned - for spelling and grammar. - """ - return 'noProof' - - @boolproperty - def outline(self): - """ - Read/write tri-state value. When |True| causes the characters in the - run to appear as if they have an outline, by drawing a one pixel wide - border around the inside and outside borders of each character glyph. - """ - return 'outline' - - @boolproperty - def rtl(self): - """ - Read/write tri-state value. When |True| causes the text in the run - to have right-to-left characteristics. - """ - return 'rtl' - - @boolproperty - def shadow(self): - """ - Read/write tri-state value. When |True| causes the text in the run - to appear as if each character has a shadow. - """ - return 'shadow' - - @boolproperty - def small_caps(self): - """ - Read/write tri-state value. When |True| causes the lowercase - characters in the run to appear as capital letters two points smaller - than the font size specified for the run. - """ - return 'smallCaps' - - @boolproperty - def snap_to_grid(self): - """ - Read/write tri-state value. When |True| causes the run to use the - document grid characters per line settings defined in the docGrid - element when laying out the characters in this run. - """ - return 'snapToGrid' - - @boolproperty - def spec_vanish(self): - """ - Read/write tri-state value. When |True|, specifies that the given run - shall always behave as if it is hidden, even when hidden text is - being displayed in the current document. The property has a very - narrow, specialized use related to the table of contents. Consult the - spec (§17.3.2.36) for more details. - """ - return 'specVanish' - - @boolproperty - def strike(self): - """ - Read/write tri-state value. When |True| causes the text in the run - to appear with a single horizontal line through the center of the - line. - """ - return 'strike' - - @property - def style(self): - """ - Read/write. The string style ID of the character style applied to - this run, or |None| if it has no directly-applied character style. - Setting this property to |None| causes any directly-applied character - style to be removed such that the run inherits character formatting - from its containing paragraph. - """ - return self._r.style - - @style.setter - def style(self, char_style): - self._r.style = char_style - - @property - def text(self): - """ - String formed by concatenating the text equivalent of each run - content child element into a Python string. Each ```` element - adds the text characters it contains. A ```` element adds - a ``\\t`` character. A ```` or ```` element each add - a ``\\n`` character. Note that a ```` element can indicate - a page break or column break as well as a line break. All ```` - elements translate to a single ``\\n`` character regardless of their - type. All other content child elements, such as ````, are - ignored. - - Assigning text to this property has the reverse effect, translating - each ``\\t`` character to a ```` element and each ``\\n`` or - ``\\r`` character to a ```` element. Any existing run content - is replaced. Run formatting is preserved. - """ - return self._r.text - - @text.setter - def text(self, text): - self._r.text = text - - @property - def underline(self): - """ - The underline style for this |Run|, one of |None|, |True|, |False|, - or a value from :ref:`WdUnderline`. A value of |None| indicates the - run has no directly-applied underline value and so will inherit the - underline value of its containing paragraph. Assigning |None| to this - property removes any directly-applied underline value. A value of - |False| indicates a directly-applied setting of no underline, - overriding any inherited value. A value of |True| indicates single - underline. The values from :ref:`WdUnderline` are used to specify - other outline styles such as double, wavy, and dotted. - """ - return self._r.underline - - @underline.setter - def underline(self, value): - self._r.underline = value - - @boolproperty - def web_hidden(self): - """ - Read/write tri-state value. When |True|, specifies that the contents - of this run shall be hidden when the document is displayed in web - page view. - """ - return 'webHidden' - - -class Text(object): - """ - Proxy object wrapping ```` element. - """ - def __init__(self, t_elm): - super(Text, self).__init__() - self._t = t_elm diff --git a/docx/text/run.py b/docx/text/run.py new file mode 100644 index 000000000..68c5905f5 --- /dev/null +++ b/docx/text/run.py @@ -0,0 +1,381 @@ +# encoding: utf-8 + +""" +Run-related proxy objects for python-docx, Run in particular. +""" + +from __future__ import absolute_import, print_function, unicode_literals + +from ..enum.text import WD_BREAK +from ..shared import Parented + + +def boolproperty(f): + """ + @boolproperty decorator. Decorated method must return the XML element + name of the boolean property element occuring under rPr. Causes + a read/write tri-state property to be added to the class having the name + of the decorated function. + """ + def _get_prop_value(parent, attr_name): + return getattr(parent, attr_name) + + def _remove_prop(parent, attr_name): + remove_method_name = '_remove_%s' % attr_name + remove_method = getattr(parent, remove_method_name) + remove_method() + + def _add_prop(parent, attr_name): + add_method_name = '_add_%s' % attr_name + add_method = getattr(parent, add_method_name) + return add_method() + + def getter(obj): + r, attr_name = obj._r, f(obj) + if r.rPr is None: + return None + prop_value = _get_prop_value(r.rPr, attr_name) + if prop_value is None: + return None + return prop_value.val + + def setter(obj, value): + if value not in (True, False, None): + raise ValueError( + "assigned value must be True, False, or None, got '%s'" + % value + ) + r, attr_name = obj._r, f(obj) + rPr = r.get_or_add_rPr() + _remove_prop(rPr, attr_name) + if value is not None: + elm = _add_prop(rPr, attr_name) + elm.val = value + + return property(getter, setter, doc=f.__doc__) + + +class Run(Parented): + """ + Proxy object wrapping ```` element. Several of the properties on Run + take a tri-state value, |True|, |False|, or |None|. |True| and |False| + correspond to on and off respectively. |None| indicates the property is + 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 = r + + def add_break(self, break_type=WD_BREAK.LINE): + """ + Add a break element of *break_type* to this run. *break_type* can + take the values `WD_BREAK.LINE`, `WD_BREAK.PAGE`, and + `WD_BREAK.COLUMN` where `WD_BREAK` is imported from `docx.enum.text`. + *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'), + }[break_type] + br = self._r.add_br() + if type_ is not None: + br.type = type_ + if clear is not None: + br.clear = clear + + def add_picture(self, image_path_or_stream, width=None, height=None): + """ + Return an |InlineShape| instance containing the image identified by + *image_path_or_stream*, added to the end of this run. + *image_path_or_stream* can be a path (a string) or a file-like object + containing a binary image. If neither width nor height is specified, + the picture appears at its native size. If only one is specified, it + is used to compute a scaling factor that is then applied to the + unspecified dimension, preserving the aspect ratio of the image. The + native size of the picture is calculated using the dots-per-inch + (dpi) value specified in the image file, defaulting to 72 dpi if no + value is specified, as is often the case. + """ + inline_shapes = self.part.inline_shapes + picture = inline_shapes.add_picture(image_path_or_stream, self) + + # scale picture dimensions if width and/or height provided + if width is not None or height is not None: + native_width, native_height = picture.width, picture.height + if width is None: + scaling_factor = float(height) / float(native_height) + width = int(round(native_width * scaling_factor)) + elif height is None: + scaling_factor = float(width) / float(native_width) + height = int(round(native_height * scaling_factor)) + # set picture to scaled dimensions + picture.width = width + picture.height = height + + return picture + + def add_tab(self): + """ + Add a ```` element at the end of the run, which Word + interprets as a tab character. + """ + self._r._add_tab() + + def add_text(self, text): + """ + Returns a newly appended |_Text| object (corresponding to a new + ```` child element) to the run, containing *text*. Compare with + the possibly more friendly approach of assigning text to the + :attr:`Run.text` property. + """ + t = self._r.add_t(text) + return _Text(t) + + @boolproperty + def all_caps(self): + """ + Read/write. Causes the text of the run to appear in capital letters. + """ + return 'caps' + + @boolproperty + def bold(self): + """ + Read/write. Causes the text of the run to appear in bold. + """ + return 'b' + + def clear(self): + """ + Return reference to this run after removing all its content. All run + formatting is preserved. + """ + self._r.clear_content() + return self + + @boolproperty + def complex_script(self): + """ + Read/write tri-state value. When |True|, causes the characters in the + run to be treated as complex script regardless of their Unicode + values. + """ + return 'cs' + + @boolproperty + def cs_bold(self): + """ + Read/write tri-state value. When |True|, causes the complex script + characters in the run to be displayed in bold typeface. + """ + return 'bCs' + + @boolproperty + def cs_italic(self): + """ + Read/write tri-state value. When |True|, causes the complex script + characters in the run to be displayed in italic typeface. + """ + return 'iCs' + + @boolproperty + def double_strike(self): + """ + Read/write tri-state value. When |True|, causes the text in the run + to appear with double strikethrough. + """ + return 'dstrike' + + @boolproperty + def emboss(self): + """ + Read/write tri-state value. When |True|, causes the text in the run + to appear as if raised off the page in relief. + """ + return 'emboss' + + @boolproperty + def hidden(self): + """ + Read/write tri-state value. When |True|, causes the text in the run + to be hidden from display, unless applications settings force hidden + text to be shown. + """ + return 'vanish' + + @boolproperty + def italic(self): + """ + Read/write tri-state value. When |True|, causes the text of the run + to appear in italics. + """ + return 'i' + + @boolproperty + def imprint(self): + """ + Read/write tri-state value. When |True|, causes the text in the run + to appear as if pressed into the page. + """ + return 'imprint' + + @boolproperty + def math(self): + """ + Read/write tri-state value. When |True|, specifies this run contains + WML that should be handled as though it was Office Open XML Math. + """ + return 'oMath' + + @boolproperty + def no_proof(self): + """ + Read/write tri-state value. When |True|, specifies that the contents + of this run should not report any errors when the document is scanned + for spelling and grammar. + """ + return 'noProof' + + @boolproperty + def outline(self): + """ + Read/write tri-state value. When |True| causes the characters in the + run to appear as if they have an outline, by drawing a one pixel wide + border around the inside and outside borders of each character glyph. + """ + return 'outline' + + @boolproperty + def rtl(self): + """ + Read/write tri-state value. When |True| causes the text in the run + to have right-to-left characteristics. + """ + return 'rtl' + + @boolproperty + def shadow(self): + """ + Read/write tri-state value. When |True| causes the text in the run + to appear as if each character has a shadow. + """ + return 'shadow' + + @boolproperty + def small_caps(self): + """ + Read/write tri-state value. When |True| causes the lowercase + characters in the run to appear as capital letters two points smaller + than the font size specified for the run. + """ + return 'smallCaps' + + @boolproperty + def snap_to_grid(self): + """ + Read/write tri-state value. When |True| causes the run to use the + document grid characters per line settings defined in the docGrid + element when laying out the characters in this run. + """ + return 'snapToGrid' + + @boolproperty + def spec_vanish(self): + """ + Read/write tri-state value. When |True|, specifies that the given run + shall always behave as if it is hidden, even when hidden text is + being displayed in the current document. The property has a very + narrow, specialized use related to the table of contents. Consult the + spec (§17.3.2.36) for more details. + """ + return 'specVanish' + + @boolproperty + def strike(self): + """ + Read/write tri-state value. When |True| causes the text in the run + to appear with a single horizontal line through the center of the + line. + """ + return 'strike' + + @property + def style(self): + """ + Read/write. The string style ID of the character style applied to + this run, or |None| if it has no directly-applied character style. + Setting this property to |None| causes any directly-applied character + style to be removed such that the run inherits character formatting + from its containing paragraph. + """ + return self._r.style + + @style.setter + def style(self, char_style): + self._r.style = char_style + + @property + def text(self): + """ + String formed by concatenating the text equivalent of each run + content child element into a Python string. Each ```` element + adds the text characters it contains. A ```` element adds + a ``\\t`` character. A ```` or ```` element each add + a ``\\n`` character. Note that a ```` element can indicate + a page break or column break as well as a line break. All ```` + elements translate to a single ``\\n`` character regardless of their + type. All other content child elements, such as ````, are + ignored. + + Assigning text to this property has the reverse effect, translating + each ``\\t`` character to a ```` element and each ``\\n`` or + ``\\r`` character to a ```` element. Any existing run content + is replaced. Run formatting is preserved. + """ + return self._r.text + + @text.setter + def text(self, text): + self._r.text = text + + @property + def underline(self): + """ + The underline style for this |Run|, one of |None|, |True|, |False|, + or a value from :ref:`WdUnderline`. A value of |None| indicates the + run has no directly-applied underline value and so will inherit the + underline value of its containing paragraph. Assigning |None| to this + property removes any directly-applied underline value. A value of + |False| indicates a directly-applied setting of no underline, + overriding any inherited value. A value of |True| indicates single + underline. The values from :ref:`WdUnderline` are used to specify + other outline styles such as double, wavy, and dotted. + """ + return self._r.underline + + @underline.setter + def underline(self, value): + self._r.underline = value + + @boolproperty + def web_hidden(self): + """ + Read/write tri-state value. When |True|, specifies that the contents + of this run shall be hidden when the document is displayed in web + page view. + """ + return 'webHidden' + + +class _Text(object): + """ + Proxy object wrapping ```` element. + """ + def __init__(self, t_elm): + super(_Text, self).__init__() + self._t = t_elm diff --git a/tests/text/test_run.py b/tests/text/test_run.py index 5803e7353..728dc4471 100644 --- a/tests/text/test_run.py +++ b/tests/text/test_run.py @@ -364,4 +364,4 @@ def picture_(self, request): @pytest.fixture def Text_(self, request): - return class_mock(request, 'docx.text.Text') + return class_mock(request, 'docx.text.run._Text') From 1a493cc16292fb43991d12ccd37d448c06e9619e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 21 Dec 2014 16:27:56 -0800 Subject: [PATCH 263/809] reorg: extract docx.text.paragraph module --- docs/api/text.rst | 6 +- docs/conf.py | 2 +- docx/blkcntnr.py | 2 +- docx/text/__init__.py | 118 ----------------------------------- docx/text/paragraph.py | 118 +++++++++++++++++++++++++++++++++++ features/steps/paragraph.py | 2 +- features/steps/text.py | 2 +- tests/parts/test_document.py | 3 +- tests/test_api.py | 5 +- tests/test_blkcntnr.py | 2 +- tests/test_table.py | 2 +- tests/text/test_paragraph.py | 5 +- tests/text/test_run.py | 3 +- 13 files changed, 138 insertions(+), 132 deletions(-) create mode 100644 docx/text/paragraph.py diff --git a/docs/api/text.rst b/docs/api/text.rst index cdb55ff61..33481af79 100644 --- a/docs/api/text.rst +++ b/docs/api/text.rst @@ -4,12 +4,12 @@ Text-related objects ==================== -.. currentmodule:: docx.text - |Paragraph| objects ------------------- +.. currentmodule:: docx.text.paragraph + .. autoclass:: Paragraph :members: @@ -17,5 +17,7 @@ Text-related objects |Run| objects ------------- +.. currentmodule:: docx.text.run + .. autoclass:: Run :members: diff --git a/docs/conf.py b/docs/conf.py index 7549dbd65..4fb3f806f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -129,7 +129,7 @@ .. |Table| replace:: :class:`.Table` -.. |Text| replace:: :class:`Text` +.. |_Text| replace:: :class:`._Text` .. |True| replace:: ``True`` diff --git a/docx/blkcntnr.py b/docx/blkcntnr.py index b11f3a50d..fde9c5793 100644 --- a/docx/blkcntnr.py +++ b/docx/blkcntnr.py @@ -9,7 +9,7 @@ from __future__ import absolute_import, print_function from .shared import Parented -from .text import Paragraph +from .text.paragraph import Paragraph class BlockItemContainer(Parented): diff --git a/docx/text/__init__.py b/docx/text/__init__.py index 1351a76b4..e69de29bb 100644 --- a/docx/text/__init__.py +++ b/docx/text/__init__.py @@ -1,118 +0,0 @@ -# encoding: utf-8 - -""" -Text-related proxy types for python-docx, such as Paragraph and Run. -""" - -from __future__ import absolute_import, print_function, unicode_literals - -from .run import Run -from ..shared import Parented - - -class Paragraph(Parented): - """ - Proxy object wrapping ```` element. - """ - def __init__(self, p, parent): - super(Paragraph, self).__init__(parent) - self._p = p - - def add_run(self, text=None, style=None): - """ - Append a run to this paragraph containing *text* and having character - style identified by style ID *style*. *text* can contain tab - (``\\t``) characters, which are converted to the appropriate XML form - for a tab. *text* can also include newline (``\\n``) or carriage - return (``\\r``) characters, each of which is converted to a line - break. - """ - r = self._p.add_r() - run = Run(r, self) - if text: - run.text = text - if style: - run.style = style - return run - - @property - def alignment(self): - """ - A member of the :ref:`WdParagraphAlignment` enumeration specifying - the justification setting for this paragraph. A value of |None| - indicates the paragraph has no directly-applied alignment value and - will inherit its alignment value from its style hierarchy. Assigning - |None| to this property removes any directly-applied alignment value. - """ - return self._p.alignment - - @alignment.setter - def alignment(self, value): - self._p.alignment = value - - def clear(self): - """ - Return this same paragraph after removing all its content. - Paragraph-level formatting, such as style, is preserved. - """ - self._p.clear_content() - return self - - def insert_paragraph_before(self, text=None, style=None): - """ - Return a newly created paragraph, inserted directly before this - paragraph. If *text* is supplied, the new paragraph contains that - text in a single run. If *style* is provided, that style is assigned - to the new paragraph. - """ - p = self._p.add_p_before() - paragraph = Paragraph(p, self._parent) - if text: - paragraph.add_run(text) - if style is not None: - paragraph.style = style - return paragraph - - @property - def runs(self): - """ - Sequence of |Run| instances corresponding to the elements in - this paragraph. - """ - return [Run(r, self) for r in self._p.r_lst] - - @property - def style(self): - """ - Paragraph style for this paragraph. Read/Write. - """ - style = self._p.style - return style if style is not None else 'Normal' - - @style.setter - def style(self, style): - self._p.style = None if style == 'Normal' else style - - @property - def text(self): - """ - String formed by concatenating the text of each run in the paragraph. - Tabs and line breaks in the XML are mapped to ``\\t`` and ``\\n`` - characters respectively. - - Assigning text to this property causes all existing paragraph content - to be replaced with a single run containing the assigned text. - A ``\\t`` character in the text is mapped to a ```` element - and each ``\\n`` or ``\\r`` character is mapped to a line break. - Paragraph-level formatting, such as style, is preserved. All - run-level formatting, such as bold or italic, is removed. - """ - text = '' - for run in self.runs: - text += run.text - return text - - @text.setter - def text(self, text): - self.clear() - self.add_run(text) diff --git a/docx/text/paragraph.py b/docx/text/paragraph.py new file mode 100644 index 000000000..5dc2f2e23 --- /dev/null +++ b/docx/text/paragraph.py @@ -0,0 +1,118 @@ +# encoding: utf-8 + +""" +Paragraph-related proxy types. +""" + +from __future__ import absolute_import, print_function, unicode_literals + +from .run import Run +from ..shared import Parented + + +class Paragraph(Parented): + """ + Proxy object wrapping ```` element. + """ + def __init__(self, p, parent): + super(Paragraph, self).__init__(parent) + self._p = p + + def add_run(self, text=None, style=None): + """ + Append a run to this paragraph containing *text* and having character + style identified by style ID *style*. *text* can contain tab + (``\\t``) characters, which are converted to the appropriate XML form + for a tab. *text* can also include newline (``\\n``) or carriage + return (``\\r``) characters, each of which is converted to a line + break. + """ + r = self._p.add_r() + run = Run(r, self) + if text: + run.text = text + if style: + run.style = style + return run + + @property + def alignment(self): + """ + A member of the :ref:`WdParagraphAlignment` enumeration specifying + the justification setting for this paragraph. A value of |None| + indicates the paragraph has no directly-applied alignment value and + will inherit its alignment value from its style hierarchy. Assigning + |None| to this property removes any directly-applied alignment value. + """ + return self._p.alignment + + @alignment.setter + def alignment(self, value): + self._p.alignment = value + + def clear(self): + """ + Return this same paragraph after removing all its content. + Paragraph-level formatting, such as style, is preserved. + """ + self._p.clear_content() + return self + + def insert_paragraph_before(self, text=None, style=None): + """ + Return a newly created paragraph, inserted directly before this + paragraph. If *text* is supplied, the new paragraph contains that + text in a single run. If *style* is provided, that style is assigned + to the new paragraph. + """ + p = self._p.add_p_before() + paragraph = Paragraph(p, self._parent) + if text: + paragraph.add_run(text) + if style is not None: + paragraph.style = style + return paragraph + + @property + def runs(self): + """ + Sequence of |Run| instances corresponding to the elements in + this paragraph. + """ + return [Run(r, self) for r in self._p.r_lst] + + @property + def style(self): + """ + Paragraph style for this paragraph. Read/Write. + """ + style = self._p.style + return style if style is not None else 'Normal' + + @style.setter + def style(self, style): + self._p.style = None if style == 'Normal' else style + + @property + def text(self): + """ + String formed by concatenating the text of each run in the paragraph. + Tabs and line breaks in the XML are mapped to ``\\t`` and ``\\n`` + characters respectively. + + Assigning text to this property causes all existing paragraph content + to be replaced with a single run containing the assigned text. + A ``\\t`` character in the text is mapped to a ```` element + and each ``\\n`` or ``\\r`` character is mapped to a line break. + Paragraph-level formatting, such as style, is preserved. All + run-level formatting, such as bold or italic, is removed. + """ + text = '' + for run in self.runs: + text += run.text + return text + + @text.setter + def text(self, text): + self.clear() + self.add_run(text) diff --git a/features/steps/paragraph.py b/features/steps/paragraph.py index cdd79bde6..59a1ceca9 100644 --- a/features/steps/paragraph.py +++ b/features/steps/paragraph.py @@ -10,7 +10,7 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml import parse_xml from docx.oxml.ns import nsdecls -from docx.text import Paragraph +from docx.text.paragraph import Paragraph from helpers import saved_docx_path, test_docx, test_text diff --git a/features/steps/text.py b/features/steps/text.py index 0d9afe97e..a65b89753 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -14,7 +14,7 @@ from docx.enum.text import WD_BREAK, WD_UNDERLINE from docx.oxml import parse_xml from docx.oxml.ns import nsdecls, qn -from docx.text import Run +from docx.text.run import Run from helpers import test_docx, test_file, test_text diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 7f94e28bf..c5f9a08cd 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -18,7 +18,8 @@ from docx.section import Section from docx.shape import InlineShape from docx.table import Table -from docx.text import Paragraph, Run +from docx.text.paragraph import Paragraph +from docx.text.run import Run from ..oxml.parts.unitdata.document import a_body, a_document from ..oxml.unitdata.text import a_p diff --git a/tests/test_api.py b/tests/test_api.py index ecf084a9b..c22230b55 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -21,7 +21,8 @@ from docx.section import Section from docx.shape import InlineShape from docx.table import Table -from docx.text import Paragraph, Run +from docx.text.paragraph import Paragraph +from docx.text.run import Run from .unitutil.mock import ( instance_mock, class_mock, method_mock, property_mock, var_mock @@ -203,7 +204,7 @@ def add_picture_fixture(self, request, run_, picture_): document = Document() image_path_ = instance_mock(request, str, name='image_path_') width, height = 100, 200 - class_mock(request, 'docx.text.Run', return_value=run_) + class_mock(request, 'docx.text.paragraph.Run', return_value=run_) run_.add_picture.return_value = picture_ return (document, image_path_, width, height, run_, picture_) diff --git a/tests/test_blkcntnr.py b/tests/test_blkcntnr.py index b8de5e400..02cd8ddab 100644 --- a/tests/test_blkcntnr.py +++ b/tests/test_blkcntnr.py @@ -10,7 +10,7 @@ from docx.blkcntnr import BlockItemContainer from docx.table import Table -from docx.text import Paragraph +from docx.text.paragraph import Paragraph from .unitutil.cxml import element, xml diff --git a/tests/test_table.py b/tests/test_table.py index 349b2f51e..741d08da0 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -13,7 +13,7 @@ from docx.oxml.table import CT_Tc from docx.shared import Inches from docx.table import _Cell, _Column, _Columns, _Row, _Rows, Table -from docx.text import Paragraph +from docx.text.paragraph import Paragraph from .oxml.unitdata.table import a_gridCol, a_tbl, a_tblGrid, a_tc, a_tr from .oxml.unitdata.text import a_p diff --git a/tests/text/test_paragraph.py b/tests/text/test_paragraph.py index 434b900a0..a08d8f0e2 100644 --- a/tests/text/test_paragraph.py +++ b/tests/text/test_paragraph.py @@ -12,7 +12,8 @@ from docx.oxml.ns import qn from docx.oxml.text.paragraph import CT_P from docx.oxml.text.run import CT_R -from docx.text import Paragraph, Run +from docx.text.paragraph import Paragraph +from docx.text.run import Run import pytest @@ -212,7 +213,7 @@ def p_(self, request, r_, r_2_): def Run_(self, request, runs_): run_, run_2_ = runs_ return class_mock( - request, 'docx.text.Run', side_effect=[run_, run_2_] + request, 'docx.text.paragraph.Run', side_effect=[run_, run_2_] ) @pytest.fixture diff --git a/tests/text/test_run.py b/tests/text/test_run.py index 728dc4471..53381c4d2 100644 --- a/tests/text/test_run.py +++ b/tests/text/test_run.py @@ -11,7 +11,8 @@ from docx.enum.text import WD_BREAK, WD_UNDERLINE from docx.parts.document import InlineShapes from docx.shape import InlineShape -from docx.text import Paragraph, Run +from docx.text.paragraph import Paragraph +from docx.text.run import Run import pytest From 013f32fce3de834beb4b7b4c696d07d5639fb88e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 27 Dec 2014 22:06:58 -0800 Subject: [PATCH 264/809] reorg: resequence oxml.shape imports --- docx/oxml/__init__.py | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index a61f65943..8b2d4a76e 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -66,27 +66,6 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): from .shared import CT_DecimalNumber, CT_OnOff, CT_String -from .shape import ( - CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, - CT_GraphicalObjectData, CT_Inline, CT_NonVisualDrawingProps, CT_Picture, - CT_PictureNonVisual, CT_Point2D, CT_PositiveSize2D, CT_ShapeProperties, - CT_Transform2D -) -register_element_cls('a:blip', CT_Blip) -register_element_cls('a:ext', CT_PositiveSize2D) -register_element_cls('a:graphic', CT_GraphicalObject) -register_element_cls('a:graphicData', CT_GraphicalObjectData) -register_element_cls('a:off', CT_Point2D) -register_element_cls('a:xfrm', CT_Transform2D) -register_element_cls('pic:blipFill', CT_BlipFillProperties) -register_element_cls('pic:cNvPr', CT_NonVisualDrawingProps) -register_element_cls('pic:nvPicPr', CT_PictureNonVisual) -register_element_cls('pic:pic', CT_Picture) -register_element_cls('pic:spPr', CT_ShapeProperties) -register_element_cls('wp:docPr', CT_NonVisualDrawingProps) -register_element_cls('wp:extent', CT_PositiveSize2D) -register_element_cls('wp:inline', CT_Inline) - from .parts.coreprops import CT_CoreProperties register_element_cls('cp:coreProperties', CT_CoreProperties) @@ -117,6 +96,27 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:sectPr', CT_SectPr) register_element_cls('w:type', CT_SectType) +from .shape import ( + CT_Blip, CT_BlipFillProperties, CT_GraphicalObject, + CT_GraphicalObjectData, CT_Inline, CT_NonVisualDrawingProps, CT_Picture, + CT_PictureNonVisual, CT_Point2D, CT_PositiveSize2D, CT_ShapeProperties, + CT_Transform2D +) +register_element_cls('a:blip', CT_Blip) +register_element_cls('a:ext', CT_PositiveSize2D) +register_element_cls('a:graphic', CT_GraphicalObject) +register_element_cls('a:graphicData', CT_GraphicalObjectData) +register_element_cls('a:off', CT_Point2D) +register_element_cls('a:xfrm', CT_Transform2D) +register_element_cls('pic:blipFill', CT_BlipFillProperties) +register_element_cls('pic:cNvPr', CT_NonVisualDrawingProps) +register_element_cls('pic:nvPicPr', CT_PictureNonVisual) +register_element_cls('pic:pic', CT_Picture) +register_element_cls('pic:spPr', CT_ShapeProperties) +register_element_cls('wp:docPr', CT_NonVisualDrawingProps) +register_element_cls('wp:extent', CT_PositiveSize2D) +register_element_cls('wp:inline', CT_Inline) + from .table import ( CT_Row, CT_Tbl, CT_TblGrid, CT_TblGridCol, CT_TblLayoutType, CT_TblPr, CT_TblWidth, CT_Tc, CT_TcPr, CT_VMerge From 71f4d031f80ff1de78cfdaa246f01a3afa53b6e1 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 01:26:06 -0800 Subject: [PATCH 265/809] shr: add ElementProxy.__eq__() --- docx/shared.py | 31 +++++++++++++++++++++++++++++++ tests/test_shared.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/test_shared.py diff --git a/docx/shared.py b/docx/shared.py index f7cd4e147..d8e501247 100644 --- a/docx/shared.py +++ b/docx/shared.py @@ -164,6 +164,37 @@ def write_only_property(f): return property(fset=f, doc=docstring) +class ElementProxy(object): + """ + Base class for lxml element proxy classes. An element proxy class is one + whose primary responsibilities are fulfilled by manipulating the + attributes and child elements of an XML element. They are the most common + type of class in python-docx other than custom element (oxml) classes. + """ + + __slots__ = ('_element',) + + def __init__(self, element): + self._element = element + + def __eq__(self, other): + """ + Return |True| if this proxy object refers to the same oxml element as + does *other*. ElementProxy objects are value objects and should + maintain no mutable local state. Equality for proxy objects is + defined as referring to the same XML element, whether or not they are + the same proxy object instance. + """ + if not isinstance(other, ElementProxy): + return False + return self._element is other._element + + def __ne__(self, other): + if not isinstance(other, ElementProxy): + return True + return self._element is not other._element + + class Parented(object): """ Provides common services for document elements that occur below a part diff --git a/tests/test_shared.py b/tests/test_shared.py new file mode 100644 index 000000000..cf281ae59 --- /dev/null +++ b/tests/test_shared.py @@ -0,0 +1,40 @@ +# encoding: utf-8 + +""" +Test suite for the docx.shared module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.shared import ElementProxy + +from .unitutil.cxml import element + + +class DescribeElementProxy(object): + + def it_knows_when_its_equal_to_another_proxy_object(self, eq_fixture): + proxy, proxy_2, proxy_3, not_a_proxy = eq_fixture + + assert (proxy == proxy_2) is True + assert (proxy == proxy_3) is False + assert (proxy == not_a_proxy) is False + + assert (proxy != proxy_2) is False + assert (proxy != proxy_3) is True + assert (proxy != not_a_proxy) is True + + # fixture -------------------------------------------------------- + + @pytest.fixture + def eq_fixture(self): + p, q = element('w:p'), element('w:p') + proxy = ElementProxy(p) + proxy_2 = ElementProxy(p) + proxy_3 = ElementProxy(q) + not_a_proxy = 'Foobar' + return proxy, proxy_2, proxy_3, not_a_proxy From 576c047fd04b3866c56a4df51704b102a7c08b96 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 01:46:35 -0800 Subject: [PATCH 266/809] shr: add ElementProxy.element --- docx/shared.py | 7 +++++++ tests/test_shared.py | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/docx/shared.py b/docx/shared.py index d8e501247..31593a662 100644 --- a/docx/shared.py +++ b/docx/shared.py @@ -194,6 +194,13 @@ def __ne__(self, other): return True return self._element is not other._element + @property + def element(self): + """ + The lxml element proxied by this object. + """ + return self._element + class Parented(object): """ diff --git a/tests/test_shared.py b/tests/test_shared.py index cf281ae59..939f0966f 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -28,8 +28,18 @@ def it_knows_when_its_equal_to_another_proxy_object(self, eq_fixture): assert (proxy != proxy_3) is True assert (proxy != not_a_proxy) is True + def it_knows_its_element(self, element_fixture): + proxy, element = element_fixture + assert proxy.element is element + # fixture -------------------------------------------------------- + @pytest.fixture + def element_fixture(self): + p = element('w:p') + proxy = ElementProxy(p) + return proxy, p + @pytest.fixture def eq_fixture(self): p, q = element('w:p'), element('w:p') From 08152f9b58dfbe27fbb06ebc30bc2332bf48d091 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 24 Dec 2014 00:28:52 -0800 Subject: [PATCH 267/809] shr: add ElementProxy.part --- docx/shared.py | 12 ++++++++++-- tests/test_shared.py | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docx/shared.py b/docx/shared.py index 31593a662..79e7c8c24 100644 --- a/docx/shared.py +++ b/docx/shared.py @@ -172,10 +172,11 @@ class ElementProxy(object): type of class in python-docx other than custom element (oxml) classes. """ - __slots__ = ('_element',) + __slots__ = ('_element', '_parent') - def __init__(self, element): + def __init__(self, element, parent=None): self._element = element + self._parent = parent def __eq__(self, other): """ @@ -201,6 +202,13 @@ def element(self): """ return self._element + @property + def part(self): + """ + The package part containing this object + """ + return self._parent.part + class Parented(object): """ diff --git a/tests/test_shared.py b/tests/test_shared.py index 939f0966f..7b8492f7d 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -10,9 +10,11 @@ import pytest +from docx.opc.part import XmlPart from docx.shared import ElementProxy from .unitutil.cxml import element +from .unitutil.mock import instance_mock class DescribeElementProxy(object): @@ -32,6 +34,10 @@ def it_knows_its_element(self, element_fixture): proxy, element = element_fixture assert proxy.element is element + def it_knows_its_part(self, part_fixture): + proxy, part_ = part_fixture + assert proxy.part is part_ + # fixture -------------------------------------------------------- @pytest.fixture @@ -48,3 +54,19 @@ def eq_fixture(self): proxy_3 = ElementProxy(q) not_a_proxy = 'Foobar' return proxy, proxy_2, proxy_3, not_a_proxy + + @pytest.fixture + def part_fixture(self, other_proxy_, part_): + other_proxy_.part = part_ + proxy = ElementProxy(None, other_proxy_) + return proxy, part_ + + # fixture components --------------------------------------------- + + @pytest.fixture + def other_proxy_(self, request): + return instance_mock(request, ElementProxy) + + @pytest.fixture + def part_(self, request): + return instance_mock(request, XmlPart) From 226b69956f8341c4ea10ef67e771097abd8392bc Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 24 Dec 2014 00:45:42 -0800 Subject: [PATCH 268/809] shr: add DescribeLength --- docx/shared.py | 30 ++++++++++------------------- tests/test_shared.py | 45 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/docx/shared.py b/docx/shared.py index 79e7c8c24..e7ade75c2 100644 --- a/docx/shared.py +++ b/docx/shared.py @@ -17,7 +17,7 @@ class Length(int): _EMUS_PER_INCH = 914400 _EMUS_PER_CM = 360000 _EMUS_PER_MM = 36000 - _EMUS_PER_PX = 12700 + _EMUS_PER_PT = 12700 _EMUS_PER_TWIP = 635 def __new__(cls, emu): @@ -52,10 +52,11 @@ def mm(self): return self / float(self._EMUS_PER_MM) @property - def px(self): - # round can somtimes return values like x.999999 which are truncated - # to x by int(); adding the 0.1 prevents this - return int(round(self / float(self._EMUS_PER_PX)) + 0.1) + def pt(self): + """ + Floating point length in points + """ + return self / float(self._EMUS_PER_PT) @property def twips(self): @@ -104,23 +105,12 @@ def __new__(cls, mm): return Length.__new__(cls, emu) -class Pt(int): - """ - Convenience class for setting font sizes in points - """ - _UNITS_PER_POINT = 100 - - def __new__(cls, pts): - units = int(pts * Pt._UNITS_PER_POINT) - return int.__new__(cls, units) - - -class Px(Length): +class Pt(Length): """ - Convenience constructor for length in pixels. + Convenience value class for specifying a length in points """ - def __new__(cls, px): - emu = int(px * Length._EMUS_PER_PX) + def __new__(cls, points): + emu = int(points * Length._EMUS_PER_PT) return Length.__new__(cls, emu) diff --git a/tests/test_shared.py b/tests/test_shared.py index 7b8492f7d..1dfb64e21 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -11,7 +11,7 @@ import pytest from docx.opc.part import XmlPart -from docx.shared import ElementProxy +from docx.shared import ElementProxy, Length, Cm, Emu, Inches, Mm, Pt, Twips from .unitutil.cxml import element from .unitutil.mock import instance_mock @@ -70,3 +70,46 @@ def other_proxy_(self, request): @pytest.fixture def part_(self, request): return instance_mock(request, XmlPart) + + +class DescribeLength(object): + + def it_can_construct_from_convenient_units(self, construct_fixture): + UnitCls, units_val, emu = construct_fixture + length = UnitCls(units_val) + assert isinstance(length, Length) + assert length == emu + + def it_can_self_convert_to_convenient_units(self, units_fixture): + emu, units_prop_name, expected_length_in_units, type_ = units_fixture + length = Length(emu) + length_in_units = getattr(length, units_prop_name) + assert length_in_units == expected_length_in_units + assert isinstance(length_in_units, type_) + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + (Length, 914400, 914400), + (Inches, 1.1, 1005840), + (Cm, 2.53, 910799), + (Emu, 9144.9, 9144), + (Mm, 13.8, 496800), + (Pt, 24.5, 311150), + (Twips, 360, 228600), + ]) + def construct_fixture(self, request): + UnitCls, units_val, emu = request.param + return UnitCls, units_val, emu + + @pytest.fixture(params=[ + (914400, 'inches', 1.0, float), + (914400, 'cm', 2.54, float), + (914400, 'emu', 914400, int), + (914400, 'mm', 25.4, float), + (914400, 'pt', 72.0, float), + (914400, 'twips', 1440, int), + ]) + def units_fixture(self, request): + emu, units_prop_name, expected_length_in_units, type_ = request.param + return emu, units_prop_name, expected_length_in_units, type_ From bdef31d3101b63854fa7fb8cccaa2e4d276f870a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 23 Dec 2014 21:16:18 -0800 Subject: [PATCH 269/809] docs: add Working with Styles page * Add some prerequisite content to Understanding Styles --- docs/conf.py | 10 +- docs/index.rst | 3 +- docs/user/quickstart.rst | 4 +- .../{styles.rst => styles-understanding.rst} | 147 +++++++++++++----- docs/user/styles-using.rst | 128 +++++++++++++++ 5 files changed, 249 insertions(+), 43 deletions(-) rename docs/user/{styles.rst => styles-understanding.rst} (66%) create mode 100644 docs/user/styles-using.rst diff --git a/docs/conf.py b/docs/conf.py index 4fb3f806f..e379d9664 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -89,6 +89,8 @@ .. |False| replace:: ``False`` +.. |Font| replace:: :class:`.Font` + .. |InlineShape| replace:: :class:`.InlineShape` .. |InlineShapes| replace:: :class:`.InlineShapes` @@ -97,6 +99,8 @@ .. |int| replace:: :class:`int` +.. |LatentStyles| replace:: :class:`.LatentStyles` + .. |Length| replace:: :class:`.Length` .. |OpcPackage| replace:: :class:`OpcPackage` @@ -117,7 +121,7 @@ .. |_Rows| replace:: :class:`_Rows` -.. |Run| replace:: :class:`Run` +.. |Run| replace:: :class:`.Run` .. |Section| replace:: :class:`.Section` @@ -125,6 +129,10 @@ .. |str| replace:: :class:`str` +.. |Style| replace:: :class:`.Style` + +.. |Styles| replace:: :class:`.Styles` + .. |StylesPart| replace:: :class:`.StylesPart` .. |Table| replace:: :class:`.Table` diff --git a/docs/index.rst b/docs/index.rst index a79fa9644..8de6a1b48 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -70,7 +70,8 @@ User Guide user/documents user/sections user/api-concepts - user/styles + user/styles-understanding + user/styles-using user/shapes user/text diff --git a/docs/user/quickstart.rst b/docs/user/quickstart.rst index 01c5f2729..6987fba44 100644 --- a/docs/user/quickstart.rst +++ b/docs/user/quickstart.rst @@ -308,7 +308,9 @@ settings, Word has *character styles* which specify a group of run-level settings. In general you can think of a character style as specifying a font, including its typeface, size, color, bold, italic, etc. -Like paragraph styles, a character style must already be defined in the document you open with the ``Document()`` call (*see* :doc:`styles`). +Like paragraph styles, a character style must already be defined in the +document you open with the ``Document()`` call (*see* +:ref:`understandingstyles`). A character style can be specified when adding a new run:: diff --git a/docs/user/styles.rst b/docs/user/styles-understanding.rst similarity index 66% rename from docs/user/styles.rst rename to docs/user/styles-understanding.rst index 87e34272d..2a8bff21f 100644 --- a/docs/user/styles.rst +++ b/docs/user/styles-understanding.rst @@ -1,3 +1,4 @@ +.. _understandingstyles: Understanding Styles ==================== @@ -27,9 +28,9 @@ text, a table, and a list, respectively. Experienced programmers will recognize styles as a level of indirection. The great thing about those is it allows you to define something once, then apply -that definition many times. This saves the work of defining the same thing over -an over; but more importantly it allows you to change it the definition and -have that change reflected in all the places you originally applied it. +that definition many times. This saves the work of defining the same thing +over an over; but more importantly it allows you to change the definition and +have that change reflected in all the places you have applied it. Why doesn't the style I applied show up? @@ -49,11 +50,11 @@ work around it, so here it is up top. The file would get a little bloated if it contained all the style definitions you could use but haven't. -#. If you apply a style that's not defined in your file (in the styles.xml part - if you're curious), Word just ignores it. It doesn't complain, it just - doesn't change how things are formatted. I'm sure there's a good reason for - this. But it can present as a bit of a puzzle if you don't understand how - Word works that way. +#. If you apply a style using |docx| that's not defined in your file (in the + styles.xml part if you're curious), Word just ignores it. It doesn't + complain, it just doesn't change how things are formatted. I'm sure + there's a good reason for this. But it can present as a bit of a puzzle if + you don't understand how Word works that way. #. When you use a style, Word adds it to the file. Once there, it stays. I imagine there's a way to get rid of it, but you have to work at it. If @@ -75,8 +76,74 @@ then deleting the paragraph works fine. That's how I got the ones below into the default template :). +Glossary +-------- + +style definition + A ```` element in the styles part of a document that explicitly + defines the attributes of a style. + +defined style + A style that is explicitly defined in a document. Contrast with *latent + style*. + +built-in style + One of the set of 276 pre-set styles built into Word, such as "Heading + 1". A built-in style can be either defined or latent. A built-in style + that is not yet defined is known as a *latent style*. Both defined and + latent built-in styles may appear as options in Word's style panel and + style gallery. + +custom style + Also known as a *user defined style*, any style defined in a Word + document that is not a built-in style. Note that a custom style cannot be + a latent style. + +latent style + A built-in style having no definition in a particular document is known + as a *latent style* in that document. A latent style can appear as an + option in the Word UI depending on the settings in the |LatentStyles| + object for the document. + +recommended style list + A list of styles that appears in the styles toolbox or panel when + "Recommended" is selected from the "List:" dropdown box. + +Style Gallery + The selection of example styles that appear in the ribbon of the Word UI + and which may be applied by clicking on one of them. + + +Identifying a style +------------------- + +A style has three identifying properties, `name`, `style_id`, and `type`. + +Each style's :attr:`name` property is its stable, unique identifier for +access purposes. + +A style's :attr:`style_id` is used internally to key a content object such as +a paragraph to its style. However this value is generated automatically by +Word and is not guaranteed to be stable across saves. In general, the style +id is formed simply by removing spaces from the *localized* style name, +however there are exceptions. Users of |docx| should generally avoid using +the style id unless they are confident with the internals involved. + +A style's :attr:`type` is set at creation time and cannot be changed. + + +Style inheritance +----------------- + +A style can inherit properties from another style, somewhat similarly to how +Cascading Style Sheets (CSS) works. Inheritance is specified using the +:attr:`~.BaseStyle.base_style` attribute. By basing one style on another, an +inheritance hierarchy of arbitrary depth can be formed. A style having no +base style inherits properties from the document defaults. + + Paragraph styles in default template -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +------------------------------------ * Normal * BodyText @@ -114,8 +181,38 @@ Paragraph styles in default template * Title +Character styles in default template +------------------------------------ + +* BodyTextChar +* BodyText2Char +* BodyText3Char +* BookTitle +* DefaultParagraphFont +* Emphasis +* Heading1Char +* Heading2Char +* Heading3Char +* Heading4Char +* Heading5Char +* Heading6Char +* Heading7Char +* Heading8Char +* Heading9Char +* IntenseEmphasis +* IntenseQuoteChar +* IntenseReference +* MacroTextChar +* QuoteChar +* Strong +* SubtitleChar +* SubtleEmphasis +* SubtleReference +* TitleChar + + Table styles in default template -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +-------------------------------- * TableNormal * ColorfulGrid @@ -217,33 +314,3 @@ Table styles in default template * MediumShading2-Accent5 * MediumShading2-Accent6 * TableGrid - - -Character styles in default template -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -* BodyText2Char -* BodyText3Char -* BodyTextChar -* BookTitle -* DefaultParagraphFont -* Emphasis -* Heading1Char -* Heading2Char -* Heading3Char -* Heading4Char -* Heading5Char -* Heading6Char -* Heading7Char -* Heading8Char -* Heading9Char -* IntenseEmphasis -* IntenseQuoteChar -* IntenseReference -* MacroTextChar -* QuoteChar -* Strong -* SubtitleChar -* SubtleEmphasis -* SubtleReference -* TitleChar diff --git a/docs/user/styles-using.rst b/docs/user/styles-using.rst new file mode 100644 index 000000000..9bd964b36 --- /dev/null +++ b/docs/user/styles-using.rst @@ -0,0 +1,128 @@ + +Working with Styles +=================== + +This page uses concepts developed in the prior page without introduction. If +a term is unfamiliar, consult the prior page :ref:`understandingstyles` for +a definition. + + +Accessing a style +----------------- + +Styles are accessed using the :attr:`.Document.styles` attribute:: + + >>> document = Document() + >>> styles = document.styles + >>> styles + + +The |Styles| object provides dictionary-style access to defined styles by +name:: + + >>> styles['Normal'] + + +.. note:: Built-in styles are stored in a WordprocessingML file using their + English name, e.g. 'Heading 1', even though users working on a localized + version of Word will see native language names in the UI, e.g. 'Kop 1'. + Because |docx| operates on the WordprocessingML file, style lookups must + use the English name. A document available on this external site allows + you to create a mapping between local language names and English style + names: + http://www.thedoctools.com/index.php?show=mt_create_style_name_list + + User-defined styles, also known as *custom styles*, are not localized and + are accessed with the name exactly as it appears in the Word UI. + +The |Styles| object is also iterable. By using the identification properties +on |Style|, various subsets of the defined styles can be generated. For +example, this code will produce a list of the defined paragraph styles:: + + >>> from docx.enum.style import WD_STYLE_TYPE + >>> styles = document.styles + >>> paragraph_styles = [ + ... s for s in styles if s.type == WD_STYLE_TYPE.PARAGRAPH + ... ] + >>> for style in paragraph_styles: + ... print(style.name) + ... + Normal + Body Text + List Bullet + + +Applying a style +---------------- + +The |Paragraph|, |Run|, and |Table| objects each have a :attr:`style` +attribute. Assigning a |Style| object of the appropriate type to this +attribute applies that style:: + + >>> document = Document() + >>> paragraph = document.add_paragraph() + >>> paragraph.style + None # inherits the default style, usually 'Normal' for a paragraph + >>> paragraph.style = document.styles['Heading 1'] + >>> paragraph.style.name + 'Heading 1' + +A style name can also be assigned directly, in which case |docx| will do the +lookup for you:: + + >>> paragraph.style = 'List Bullet' + >>> paragraph.style + + >>> paragraph.style.name + 'List Bullet' + +A style can also be applied at creation time using either the |Style| object +or its name:: + + >>> paragraph = document.add_paragraph(style='Heading 1') + >>> paragraph.style.name + 'Heading 1' + >>> heading_1_style = document.styles['Heading 1'] + >>> paragraph = document.add_paragraph(style=heading_1_style) + >>> paragraph.style.name + 'Heading 1' + + +Controlling how a style appears in the Word UI +---------------------------------------------- + +The properties of a style fall into two categories, *behavioral properties* +and *formatting properties*. Its behavioral properties control when and where +the style appears in the Word UI. Its formatting properties determine the +formatting of content to which the style is applied, such as the size of the +font and its paragraph indentation. + +There are five behavioral properties of a style: + +* :attr:`~.Style.hidden` +* :attr:`~.Style.unhide_when_used` +* :attr:`~.Style.priority` +* :attr:`~.Style.quick_style` +* :attr:`~.Style.locked` + +The key notion to understanding the behavioral properties is the *recommended +list*. In the style pane in Word, the user can select which list of styles +they want to see. One of those is named *Recommended*. All five behavior +properties affect some aspect of the style's appearance in this list and in +the style gallery. + +In brief, a style appears in the recommended list if its `hidden` property is +|False|. If a style is not hidden and its `quick_style` property is |True|, +it also appears in the style gallery. The style's `priority` value (|int|) +determines its position in the sequence of styles. If a styles's `locked` +property is |True| and formatting restrictions are turned on for the +document, the style will not appear in any list or the style gallery and +cannot be applied to content. + + +Working with Latent Styles +-------------------------- + +... describe latent styles in Understanding page ... + +Let's illustrate these behaviors with a few examples. From 1fff811afca47397f47be2119cea24ee934a1669 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 00:13:57 -0800 Subject: [PATCH 270/809] docs: document Styles feature analysis --- docs/api/enum/WdBuiltinStyle.rst | 415 +++++++++++++++++++ docs/api/enum/WdStyleType.rst | 29 ++ docs/api/enum/index.rst | 2 + docs/dev/analysis/features/coreprops.rst | 4 +- docs/dev/analysis/features/doc-styles.rst | 469 ++++++++++++++++++++++ docs/dev/analysis/index.rst | 2 +- docs/dev/analysis/schema/ct_styles.rst | 120 ------ docx/enum/style.py | 466 +++++++++++++++++++++ 8 files changed, 1384 insertions(+), 123 deletions(-) create mode 100644 docs/api/enum/WdBuiltinStyle.rst create mode 100644 docs/api/enum/WdStyleType.rst create mode 100644 docs/dev/analysis/features/doc-styles.rst delete mode 100644 docs/dev/analysis/schema/ct_styles.rst create mode 100644 docx/enum/style.py diff --git a/docs/api/enum/WdBuiltinStyle.rst b/docs/api/enum/WdBuiltinStyle.rst new file mode 100644 index 000000000..b7aa682d4 --- /dev/null +++ b/docs/api/enum/WdBuiltinStyle.rst @@ -0,0 +1,415 @@ +.. _WdBuiltinStyle: + +``WD_BUILTIN_STYLE`` +==================== + +alias: **WD_STYLE** + +Specifies a built-in Microsoft Word style. + +Example:: + + from docx import Document + from docx.enum.style import WD_STYLE + + document = Document() + styles = document.styles + style = styles[WD_STYLE.BODY_TEXT] + +---- + +BLOCK_QUOTATION + Block Text. + +BODY_TEXT + Body Text. + +BODY_TEXT_2 + Body Text 2. + +BODY_TEXT_3 + Body Text 3. + +BODY_TEXT_FIRST_INDENT + Body Text First Indent. + +BODY_TEXT_FIRST_INDENT_2 + Body Text First Indent 2. + +BODY_TEXT_INDENT + Body Text Indent. + +BODY_TEXT_INDENT_2 + Body Text Indent 2. + +BODY_TEXT_INDENT_3 + Body Text Indent 3. + +BOOK_TITLE + Book Title. + +CAPTION + Caption. + +CLOSING + Closing. + +COMMENT_REFERENCE + Comment Reference. + +COMMENT_TEXT + Comment Text. + +DATE + Date. + +DEFAULT_PARAGRAPH_FONT + Default Paragraph Font. + +EMPHASIS + Emphasis. + +ENDNOTE_REFERENCE + Endnote Reference. + +ENDNOTE_TEXT + Endnote Text. + +ENVELOPE_ADDRESS + Envelope Address. + +ENVELOPE_RETURN + Envelope Return. + +FOOTER + Footer. + +FOOTNOTE_REFERENCE + Footnote Reference. + +FOOTNOTE_TEXT + Footnote Text. + +HEADER + Header. + +HEADING_1 + Heading 1. + +HEADING_2 + Heading 2. + +HEADING_3 + Heading 3. + +HEADING_4 + Heading 4. + +HEADING_5 + Heading 5. + +HEADING_6 + Heading 6. + +HEADING_7 + Heading 7. + +HEADING_8 + Heading 8. + +HEADING_9 + Heading 9. + +HTML_ACRONYM + HTML Acronym. + +HTML_ADDRESS + HTML Address. + +HTML_CITE + HTML Cite. + +HTML_CODE + HTML Code. + +HTML_DFN + HTML Definition. + +HTML_KBD + HTML Keyboard. + +HTML_NORMAL + Normal (Web). + +HTML_PRE + HTML Preformatted. + +HTML_SAMP + HTML Sample. + +HTML_TT + HTML Typewriter. + +HTML_VAR + HTML Variable. + +HYPERLINK + Hyperlink. + +HYPERLINK_FOLLOWED + Followed Hyperlink. + +INDEX_1 + Index 1. + +INDEX_2 + Index 2. + +INDEX_3 + Index 3. + +INDEX_4 + Index 4. + +INDEX_5 + Index 5. + +INDEX_6 + Index 6. + +INDEX_7 + Index 7. + +INDEX_8 + Index 8. + +INDEX_9 + Index 9. + +INDEX_HEADING + Index Heading + +INTENSE_EMPHASIS + Intense Emphasis. + +INTENSE_QUOTE + Intense Quote. + +INTENSE_REFERENCE + Intense Reference. + +LINE_NUMBER + Line Number. + +LIST + List. + +LIST_2 + List 2. + +LIST_3 + List 3. + +LIST_4 + List 4. + +LIST_5 + List 5. + +LIST_BULLET + List Bullet. + +LIST_BULLET_2 + List Bullet 2. + +LIST_BULLET_3 + List Bullet 3. + +LIST_BULLET_4 + List Bullet 4. + +LIST_BULLET_5 + List Bullet 5. + +LIST_CONTINUE + List Continue. + +LIST_CONTINUE_2 + List Continue 2. + +LIST_CONTINUE_3 + List Continue 3. + +LIST_CONTINUE_4 + List Continue 4. + +LIST_CONTINUE_5 + List Continue 5. + +LIST_NUMBER + List Number. + +LIST_NUMBER_2 + List Number 2. + +LIST_NUMBER_3 + List Number 3. + +LIST_NUMBER_4 + List Number 4. + +LIST_NUMBER_5 + List Number 5. + +LIST_PARAGRAPH + List Paragraph. + +MACRO_TEXT + Macro Text. + +MESSAGE_HEADER + Message Header. + +NAV_PANE + Document Map. + +NORMAL + Normal. + +NORMAL_INDENT + Normal Indent. + +NORMAL_OBJECT + Normal (applied to an object). + +NORMAL_TABLE + Normal (applied within a table). + +NOTE_HEADING + Note Heading. + +PAGE_NUMBER + Page Number. + +PLAIN_TEXT + Plain Text. + +QUOTE + Quote. + +SALUTATION + Salutation. + +SIGNATURE + Signature. + +STRONG + Strong. + +SUBTITLE + Subtitle. + +SUBTLE_EMPHASIS + Subtle Emphasis. + +SUBTLE_REFERENCE + Subtle Reference. + +TABLE_COLORFUL_GRID + Colorful Grid. + +TABLE_COLORFUL_LIST + Colorful List. + +TABLE_COLORFUL_SHADING + Colorful Shading. + +TABLE_DARK_LIST + Dark List. + +TABLE_LIGHT_GRID + Light Grid. + +TABLE_LIGHT_GRID_ACCENT_1 + Light Grid Accent 1. + +TABLE_LIGHT_LIST + Light List. + +TABLE_LIGHT_LIST_ACCENT_1 + Light List Accent 1. + +TABLE_LIGHT_SHADING + Light Shading. + +TABLE_LIGHT_SHADING_ACCENT_1 + Light Shading Accent 1. + +TABLE_MEDIUM_GRID_1 + Medium Grid 1. + +TABLE_MEDIUM_GRID_2 + Medium Grid 2. + +TABLE_MEDIUM_GRID_3 + Medium Grid 3. + +TABLE_MEDIUM_LIST_1 + Medium List 1. + +TABLE_MEDIUM_LIST_1_ACCENT_1 + Medium List 1 Accent 1. + +TABLE_MEDIUM_LIST_2 + Medium List 2. + +TABLE_MEDIUM_SHADING_1 + Medium Shading 1. + +TABLE_MEDIUM_SHADING_1_ACCENT_1 + Medium Shading 1 Accent 1. + +TABLE_MEDIUM_SHADING_2 + Medium Shading 2. + +TABLE_MEDIUM_SHADING_2_ACCENT_1 + Medium Shading 2 Accent 1. + +TABLE_OF_AUTHORITIES + Table of Authorities. + +TABLE_OF_FIGURES + Table of Figures. + +TITLE + Title. + +TOAHEADING + TOA Heading. + +TOC_1 + TOC 1. + +TOC_2 + TOC 2. + +TOC_3 + TOC 3. + +TOC_4 + TOC 4. + +TOC_5 + TOC 5. + +TOC_6 + TOC 6. + +TOC_7 + TOC 7. + +TOC_8 + TOC 8. + +TOC_9 + TOC 9. diff --git a/docs/api/enum/WdStyleType.rst b/docs/api/enum/WdStyleType.rst new file mode 100644 index 000000000..4a4a3213b --- /dev/null +++ b/docs/api/enum/WdStyleType.rst @@ -0,0 +1,29 @@ +.. _WdStyleType: + +``WD_STYLE_TYPE`` +================= + +Specifies one of the four style types: paragraph, character, list, or +table. + +Example:: + + from docx import Document + from docx.enum.style import WD_STYLE_TYPE + + styles = Document().styles + assert styles[0].type == WD_STYLE_TYPE.PARAGRAPH + +---- + +CHARACTER + Character style. + +LIST + List style. + +PARAGRAPH + Paragraph style. + +TABLE + Table style. diff --git a/docs/api/enum/index.rst b/docs/api/enum/index.rst index 826994660..af3204f23 100644 --- a/docs/api/enum/index.rst +++ b/docs/api/enum/index.rst @@ -9,7 +9,9 @@ can be found here: :titlesonly: WdAlignParagraph + WdBuiltinStyle WdOrientation WdSectionStart + WdStyleType WdRowAlignment WdUnderline diff --git a/docs/dev/analysis/features/coreprops.rst b/docs/dev/analysis/features/coreprops.rst index a5b2c47d5..d4100864d 100644 --- a/docs/dev/analysis/features/coreprops.rst +++ b/docs/dev/analysis/features/coreprops.rst @@ -131,8 +131,8 @@ core.xml produced by Microsoft Word:: -Schema -====== +Schema Excerpt +-------------- :: diff --git a/docs/dev/analysis/features/doc-styles.rst b/docs/dev/analysis/features/doc-styles.rst new file mode 100644 index 000000000..18d0128d6 --- /dev/null +++ b/docs/dev/analysis/features/doc-styles.rst @@ -0,0 +1,469 @@ + +Styles +====== + +Word supports the definition of *styles* to allow a group of formatting +properties to be easily and consistently applied to a paragraph, run, table, +or numbering scheme; all at once. The mechanism is similar to how Cascading +Style Sheets (CSS) works with HTML. + +Styles are defined in the ``styles.xml`` package part and are keyed to +a paragraph, run, or table using the `styleId` string. + + +Latent style candidate protocol +------------------------------- + +:: + + >>> latent_styles = document.styles.latent_styles + >>> latent_styles + + + >>> latent_styles.default_locked_state + False + >>> latent_styles.default_locked_state = True + >>> latent_styles.default_locked_state + True + + >>> latent_styles.default_hidden + False + >>> latent_styles.default_hidden = True + >>> latent_styles.default_hidden + True + + >>> exception = latent_styles.exceptions[0] + + >>> exception.name + 'Normal' + + >>> exception.priority + None + >>> exception.priority = 10 + >>> exception.priority + True + + >>> exception.locked + None + >>> exception.locked = True + >>> exception.locked + True + + >>> exception.quick_style + None + >>> exception.quick_style = True + >>> exception.quick_style + True + + +Latent style behavior +--------------------- + +* A style has two categories of attribute, *behavioral* and *formatting*. + Behavioral attributes specify where and when the style should appear in the + user interface. Behavioral attributes can be specified for latent styles + using the ```` element and its ```` child + elements. The 5 behavioral attributes are: + + + locked + + uiPriority + + semiHidden + + unhideWhenUsed + + qFormat + +* **locked**. The `locked` attribute specifies that the style should not + appear in any list or the gallery and may not be applied to content. This + behavior is only active when restricted formatting is turned on. + + Locking is turned on via the menu: Developer Tab > Protect Document > + Formatting Restrictions (Windows only). + +* **uiPriority**. The `uiPriority` attribute acts as a sort key for + sequencing style names in the user interface. Both the lists in the styles + panel and the Style Gallery are sensitive to this setting. Its effective + value is 0 if not specified. + +* **semiHidden**. The `semiHidden` attribute causes the style to be excluded + from the recommended list. The notion of *semi* in this context is that + while the style is hidden from the recommended list, it still appears in + the "All Styles" list. This attribute is removed on first application of + the style if an `unhideWhenUsed` attribute set |True| is also present. + +* **unhideWhenUsed**. The `unhideWhenUsed` attribute causes any `semiHidden` + attribute to be removed when the style is first applied to content. Word + does *not* remove the `semiHidden` attribute just because there exists an + object in the document having that style. The `unhideWhenUsed` attribute is + not removed along with the `semiHidden` attribute when the style is + applied. + + The `semiHidden` and `unhideWhenUsed` attributes operate in combination to + produce *hide-until-used* behavior. + + *Hypothesis.* The persistance of the `unhideWhenUsed` attribute after + removing the `semiHidden` attribute on first application of the style is + necessary to produce appropriate behavior in style inheritance situations. + In that case, the `semiHidden` attribute may be explictly set to |False| to + override an inherited value. Or it could allow the `semiHidden` attribute + to be re-set to |True| later while preserving the hide-until-used behavior. + +* **qFormat**. The `qFormat` attribute specifies whether the style should + appear in the Style Gallery when it appears in the recommended list. + A style will never appear in the gallery unless it also appears in the + recommended list. + +* Latent style attributes are only operative for latent styles. Once a style + is defined, the attributes of the definition exclusively determine style + behavior; no attributes are inherited from its corresponding latent style + definition. + + +Style visual behavior +--------------------- + +* **Sort order.** Built-in styles appear in order of the effective value of + their `uiPriority` attribute. By default, a custom style will not receive + a `uiPriority` attribute, causing its effective value to default to 0. This + will generlly place custom styles at the top of the sort order. A set of + styles having the same `uiPriority` value will be sub-sorted in + alphabetical order. + + If a `uiPriority` attribute is defined for a custom style, that style is + interleaved with the built-in styles, according to their `uiPriority` + value. The `uiPriority` attribute takes a signed integer, and accepts + negative numbers. Note that Word does not allow the use of negative + integers via its UI; rather it allows the `uiPriority` number of built-in + types to be increased to produce the desired sorting behavior. + +* **Identification.** A style is identified by its name, not its styleId + attribute. The styleId is used only for internal linking of an object like + a paragraph to a style. The styleId may be changed by the application, and + in fact is routinely changed by Word on each save to be a transformation of + the name. + + *Hypothesis.* Word calculates the `styleId` by removing all spaces from the + style name. + +* **List membership.** There are four style list options in the styles panel: + + + *Recommended.* The recommended list contains all latent and defined + styles that have `semiHidden` == |False|. + + + *Styles in Use.* The styles-in-use list contains all styles that have + been applied to content in the document (implying they are defined) that + also have `semiHidden` == |False|. + + + *In Current Document.* The in-current-document list contains all defined + styles in the document having `semiHidden` == |False|. + + + *All Styles.* The all-styles list contains all latent and defined + styles in the document. + +* **Definition of built-in style.** When a built-in style is added to + a document (upon first use), the value of each of the `locked`, + `uiPriority` and `qFormat` attributes from its latent style definition (the + `latentStyles` attributes overridden by those of any `lsdException` + element) is used to override the corresponding value in the inserted style + definition from their built-in defaults. + +* Each built-in style has default attributes that can be revealed by setting + the `latentStyles/@count` attribute to 0 and inspecting the style in the + style manager. This may include default behavioral properties. + +* Anomaly. Style "No Spacing" does not appear in the recommended list even + though its behavioral attributes indicate it should. (Google indicates it + may be a legacy style from Word 2003). + +* Word has 267 built-in styles, listed here: + http://www.thedoctools.com/downloads/DocTools_List_Of_Built-in_Style_English_Danish_German_French.pdf + + Note that at least one other sources has the number at 276 rather than 267. + +* **Appearance in the Style Gallery.** A style appears in the style gallery + when: `semiHidden` == |False| and `qFormat` == |True| + + +Glossary +-------- + +built-in style + One of a set of standard styles known to Word, such as "Heading 1". + Built-in styles are presented in Word's style panel whether or not they + are actually defined in the styles part. + +latent style + A built-in style having no definition in a particular document is known + as a *latent style* in that document. + +style definition + A ```` element in the styles part that explicitly defines the + attributes of a style. + +recommended style list + A list of styles that appears in the styles toolbox or panel when + "Recommended" is selected from the "List:" dropdown box. + + +Word behavior +------------- + +If no style having an assigned style id is defined in the styles part, the +style application has no effect. + +Word does not add a formatting definition (```` element) for a +built-in style until it is used. + +Once present in the styles part, Word does not remove a built-in style +definition if it is no longer applied to any content. The definition of each +of the styles ever used in a document are accumulated in its ``styles.xml``. + + +Candidate protocol +------------------ + +:: + + >>> styles = document.styles # default styles part added if not present + >>> list_styles = [s for s in styles if s.type == WD_STYLE_TYPE.LIST] + >>> list_styles[0].type + WD_STYLE_TYPE.LIST (4) + + >>> style = styles.add_style('Citation', WD_STYLE_TYPE.PARAGRAPH) + + >>> document.add_paragraph(style='undefined-style') + KeyError: no style with id 'undefined-style' + + +Feature Notes +------------- + +* could add a default builtin style from known specs on first access via + WD_BUILTIN_STYLE enumeration:: + + >>> style = document.styles['Heading1'] + KeyError: no style with id or name 'Heading1' + >>> style = document.styles[WD_STYLE.HEADING_1] + >>> assert style == document.styles['Heading1'] + + +Related MS API *(partial)* +-------------------------- + +* Document.Styles +* Styles.Add, .Item, .Count, access by name, e.g. Styles("Foobar") +* Style.BaseStyle +* Style.Builtin +* Style.Delete() +* Style.Description +* Style.Font +* Style.Linked +* Style.LinkStyle +* Style.LinkToListTemplate() +* Style.ListLevelNumber +* Style.ListTemplate +* Style.Locked +* Style.NameLocal +* Style.NameParagraphStyle +* Style.NoSpaceBetweenParagraphsOfSameStyle +* Style.ParagraphFormat +* Style.Priority +* Style.QuickStyle +* Style.Shading +* Style.Table(Style) +* Style.Type +* Style.UnhideWhenUsed +* Style.Visibility + + +Enumerations +------------ + +* WdBuiltinStyle + + +Spec text +--------- + + This element specifies all of the style information stored in the + WordprocessingML document: style definitions as well as latent style + information. + + Example: The Normal paragraph style in a word processing document can have + any number of formatting properties, e.g. font face = Times New Roman; font + size = 12pt; paragraph justification = left. All paragraphs which reference + this paragraph style would automatically inherit these properties. + + +Example XML +----------- + +.. highlight:: xml + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Schema excerpt +-------------- + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index b17d7524e..10633d672 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,7 @@ Feature Analysis .. toctree:: :maxdepth: 1 + features/doc-styles features/coreprops features/cell-merge features/table @@ -42,4 +43,3 @@ ISO/IEC 29500 spec. schema/ct_body schema/ct_p schema/ct_ppr - schema/ct_styles diff --git a/docs/dev/analysis/schema/ct_styles.rst b/docs/dev/analysis/schema/ct_styles.rst deleted file mode 100644 index 1977acc8a..000000000 --- a/docs/dev/analysis/schema/ct_styles.rst +++ /dev/null @@ -1,120 +0,0 @@ - -``CT_Styles`` -============= - -.. highlight:: xml - -.. csv-table:: - :header-rows: 0 - :stub-columns: 1 - :widths: 15, 50 - - Schema Name, CT_Styles - Spec Name, Styles - Tag(s), w:styles - Namespace, wordprocessingml (wml.xsd) - Spec Section, 17.7.4.18 - - -Analysis --------- - -Only styles with an explicit ```` definition affect the formatting -of paragraphs that are assigned that style. - -Word includes behavior definitions (```` elements) for the -"latent" styles that are built in to the Word client. These are present in a -new document created from install defaults. - -Word does not add a formatting definition (```` element) for a -built-in style until it is used. - -Once present in ``styles.xml``, Word does not remove a style element when it -is no longer used by any paragraphs. The definition of each of the styles -ever used in a document are accumulated in ``styles.xml``. - - -Spec text ---------- - - This element specifies all of the style information stored in the - WordprocessingML document: style definitions as well as latent style - information. - - Example: The Normal paragraph style in a word processing document can have - any number of formatting properties, e.g. font face = Times New Roman; font - size = 12pt; paragraph justification = left. All paragraphs which reference - this paragraph style would automatically inherit these properties. - - -Schema excerpt --------------- - -:: - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docx/enum/style.py b/docx/enum/style.py new file mode 100644 index 000000000..515c594ce --- /dev/null +++ b/docx/enum/style.py @@ -0,0 +1,466 @@ +# encoding: utf-8 + +""" +Enumerations related to styles +""" + +from __future__ import absolute_import, print_function, unicode_literals + +from .base import alias, EnumMember, XmlEnumeration, XmlMappedEnumMember + + +@alias('WD_STYLE') +class WD_BUILTIN_STYLE(XmlEnumeration): + """ + alias: **WD_STYLE** + + Specifies a built-in Microsoft Word style. + + Example:: + + from docx import Document + from docx.enum.style import WD_STYLE + + document = Document() + styles = document.styles + style = styles[WD_STYLE.BODY_TEXT] + """ + + __ms_name__ = 'WdBuiltinStyle' + + __url__ = 'http://msdn.microsoft.com/en-us/library/office/ff835210.aspx' + + __members__ = ( + EnumMember( + 'BLOCK_QUOTATION', -85, 'Block Text.' + ), + EnumMember( + 'BODY_TEXT', -67, 'Body Text.' + ), + EnumMember( + 'BODY_TEXT_2', -81, 'Body Text 2.' + ), + EnumMember( + 'BODY_TEXT_3', -82, 'Body Text 3.' + ), + EnumMember( + 'BODY_TEXT_FIRST_INDENT', -78, 'Body Text First Indent.' + ), + EnumMember( + 'BODY_TEXT_FIRST_INDENT_2', -79, 'Body Text First Indent 2.' + ), + EnumMember( + 'BODY_TEXT_INDENT', -68, 'Body Text Indent.' + ), + EnumMember( + 'BODY_TEXT_INDENT_2', -83, 'Body Text Indent 2.' + ), + EnumMember( + 'BODY_TEXT_INDENT_3', -84, 'Body Text Indent 3.' + ), + EnumMember( + 'BOOK_TITLE', -265, 'Book Title.' + ), + EnumMember( + 'CAPTION', -35, 'Caption.' + ), + EnumMember( + 'CLOSING', -64, 'Closing.' + ), + EnumMember( + 'COMMENT_REFERENCE', -40, 'Comment Reference.' + ), + EnumMember( + 'COMMENT_TEXT', -31, 'Comment Text.' + ), + EnumMember( + 'DATE', -77, 'Date.' + ), + EnumMember( + 'DEFAULT_PARAGRAPH_FONT', -66, 'Default Paragraph Font.' + ), + EnumMember( + 'EMPHASIS', -89, 'Emphasis.' + ), + EnumMember( + 'ENDNOTE_REFERENCE', -43, 'Endnote Reference.' + ), + EnumMember( + 'ENDNOTE_TEXT', -44, 'Endnote Text.' + ), + EnumMember( + 'ENVELOPE_ADDRESS', -37, 'Envelope Address.' + ), + EnumMember( + 'ENVELOPE_RETURN', -38, 'Envelope Return.' + ), + EnumMember( + 'FOOTER', -33, 'Footer.' + ), + EnumMember( + 'FOOTNOTE_REFERENCE', -39, 'Footnote Reference.' + ), + EnumMember( + 'FOOTNOTE_TEXT', -30, 'Footnote Text.' + ), + EnumMember( + 'HEADER', -32, 'Header.' + ), + EnumMember( + 'HEADING_1', -2, 'Heading 1.' + ), + EnumMember( + 'HEADING_2', -3, 'Heading 2.' + ), + EnumMember( + 'HEADING_3', -4, 'Heading 3.' + ), + EnumMember( + 'HEADING_4', -5, 'Heading 4.' + ), + EnumMember( + 'HEADING_5', -6, 'Heading 5.' + ), + EnumMember( + 'HEADING_6', -7, 'Heading 6.' + ), + EnumMember( + 'HEADING_7', -8, 'Heading 7.' + ), + EnumMember( + 'HEADING_8', -9, 'Heading 8.' + ), + EnumMember( + 'HEADING_9', -10, 'Heading 9.' + ), + EnumMember( + 'HTML_ACRONYM', -96, 'HTML Acronym.' + ), + EnumMember( + 'HTML_ADDRESS', -97, 'HTML Address.' + ), + EnumMember( + 'HTML_CITE', -98, 'HTML Cite.' + ), + EnumMember( + 'HTML_CODE', -99, 'HTML Code.' + ), + EnumMember( + 'HTML_DFN', -100, 'HTML Definition.' + ), + EnumMember( + 'HTML_KBD', -101, 'HTML Keyboard.' + ), + EnumMember( + 'HTML_NORMAL', -95, 'Normal (Web).' + ), + EnumMember( + 'HTML_PRE', -102, 'HTML Preformatted.' + ), + EnumMember( + 'HTML_SAMP', -103, 'HTML Sample.' + ), + EnumMember( + 'HTML_TT', -104, 'HTML Typewriter.' + ), + EnumMember( + 'HTML_VAR', -105, 'HTML Variable.' + ), + EnumMember( + 'HYPERLINK', -86, 'Hyperlink.' + ), + EnumMember( + 'HYPERLINK_FOLLOWED', -87, 'Followed Hyperlink.' + ), + EnumMember( + 'INDEX_1', -11, 'Index 1.' + ), + EnumMember( + 'INDEX_2', -12, 'Index 2.' + ), + EnumMember( + 'INDEX_3', -13, 'Index 3.' + ), + EnumMember( + 'INDEX_4', -14, 'Index 4.' + ), + EnumMember( + 'INDEX_5', -15, 'Index 5.' + ), + EnumMember( + 'INDEX_6', -16, 'Index 6.' + ), + EnumMember( + 'INDEX_7', -17, 'Index 7.' + ), + EnumMember( + 'INDEX_8', -18, 'Index 8.' + ), + EnumMember( + 'INDEX_9', -19, 'Index 9.' + ), + EnumMember( + 'INDEX_HEADING', -34, 'Index Heading' + ), + EnumMember( + 'INTENSE_EMPHASIS', -262, 'Intense Emphasis.' + ), + EnumMember( + 'INTENSE_QUOTE', -182, 'Intense Quote.' + ), + EnumMember( + 'INTENSE_REFERENCE', -264, 'Intense Reference.' + ), + EnumMember( + 'LINE_NUMBER', -41, 'Line Number.' + ), + EnumMember( + 'LIST', -48, 'List.' + ), + EnumMember( + 'LIST_2', -51, 'List 2.' + ), + EnumMember( + 'LIST_3', -52, 'List 3.' + ), + EnumMember( + 'LIST_4', -53, 'List 4.' + ), + EnumMember( + 'LIST_5', -54, 'List 5.' + ), + EnumMember( + 'LIST_BULLET', -49, 'List Bullet.' + ), + EnumMember( + 'LIST_BULLET_2', -55, 'List Bullet 2.' + ), + EnumMember( + 'LIST_BULLET_3', -56, 'List Bullet 3.' + ), + EnumMember( + 'LIST_BULLET_4', -57, 'List Bullet 4.' + ), + EnumMember( + 'LIST_BULLET_5', -58, 'List Bullet 5.' + ), + EnumMember( + 'LIST_CONTINUE', -69, 'List Continue.' + ), + EnumMember( + 'LIST_CONTINUE_2', -70, 'List Continue 2.' + ), + EnumMember( + 'LIST_CONTINUE_3', -71, 'List Continue 3.' + ), + EnumMember( + 'LIST_CONTINUE_4', -72, 'List Continue 4.' + ), + EnumMember( + 'LIST_CONTINUE_5', -73, 'List Continue 5.' + ), + EnumMember( + 'LIST_NUMBER', -50, 'List Number.' + ), + EnumMember( + 'LIST_NUMBER_2', -59, 'List Number 2.' + ), + EnumMember( + 'LIST_NUMBER_3', -60, 'List Number 3.' + ), + EnumMember( + 'LIST_NUMBER_4', -61, 'List Number 4.' + ), + EnumMember( + 'LIST_NUMBER_5', -62, 'List Number 5.' + ), + EnumMember( + 'LIST_PARAGRAPH', -180, 'List Paragraph.' + ), + EnumMember( + 'MACRO_TEXT', -46, 'Macro Text.' + ), + EnumMember( + 'MESSAGE_HEADER', -74, 'Message Header.' + ), + EnumMember( + 'NAV_PANE', -90, 'Document Map.' + ), + EnumMember( + 'NORMAL', -1, 'Normal.' + ), + EnumMember( + 'NORMAL_INDENT', -29, 'Normal Indent.' + ), + EnumMember( + 'NORMAL_OBJECT', -158, 'Normal (applied to an object).' + ), + EnumMember( + 'NORMAL_TABLE', -106, 'Normal (applied within a table).' + ), + EnumMember( + 'NOTE_HEADING', -80, 'Note Heading.' + ), + EnumMember( + 'PAGE_NUMBER', -42, 'Page Number.' + ), + EnumMember( + 'PLAIN_TEXT', -91, 'Plain Text.' + ), + EnumMember( + 'QUOTE', -181, 'Quote.' + ), + EnumMember( + 'SALUTATION', -76, 'Salutation.' + ), + EnumMember( + 'SIGNATURE', -65, 'Signature.' + ), + EnumMember( + 'STRONG', -88, 'Strong.' + ), + EnumMember( + 'SUBTITLE', -75, 'Subtitle.' + ), + EnumMember( + 'SUBTLE_EMPHASIS', -261, 'Subtle Emphasis.' + ), + EnumMember( + 'SUBTLE_REFERENCE', -263, 'Subtle Reference.' + ), + EnumMember( + 'TABLE_COLORFUL_GRID', -172, 'Colorful Grid.' + ), + EnumMember( + 'TABLE_COLORFUL_LIST', -171, 'Colorful List.' + ), + EnumMember( + 'TABLE_COLORFUL_SHADING', -170, 'Colorful Shading.' + ), + EnumMember( + 'TABLE_DARK_LIST', -169, 'Dark List.' + ), + EnumMember( + 'TABLE_LIGHT_GRID', -161, 'Light Grid.' + ), + EnumMember( + 'TABLE_LIGHT_GRID_ACCENT_1', -175, 'Light Grid Accent 1.' + ), + EnumMember( + 'TABLE_LIGHT_LIST', -160, 'Light List.' + ), + EnumMember( + 'TABLE_LIGHT_LIST_ACCENT_1', -174, 'Light List Accent 1.' + ), + EnumMember( + 'TABLE_LIGHT_SHADING', -159, 'Light Shading.' + ), + EnumMember( + 'TABLE_LIGHT_SHADING_ACCENT_1', -173, 'Light Shading Accent 1.' + ), + EnumMember( + 'TABLE_MEDIUM_GRID_1', -166, 'Medium Grid 1.' + ), + EnumMember( + 'TABLE_MEDIUM_GRID_2', -167, 'Medium Grid 2.' + ), + EnumMember( + 'TABLE_MEDIUM_GRID_3', -168, 'Medium Grid 3.' + ), + EnumMember( + 'TABLE_MEDIUM_LIST_1', -164, 'Medium List 1.' + ), + EnumMember( + 'TABLE_MEDIUM_LIST_1_ACCENT_1', -178, 'Medium List 1 Accent 1.' + ), + EnumMember( + 'TABLE_MEDIUM_LIST_2', -165, 'Medium List 2.' + ), + EnumMember( + 'TABLE_MEDIUM_SHADING_1', -162, 'Medium Shading 1.' + ), + EnumMember( + 'TABLE_MEDIUM_SHADING_1_ACCENT_1', -176, + 'Medium Shading 1 Accent 1.' + ), + EnumMember( + 'TABLE_MEDIUM_SHADING_2', -163, 'Medium Shading 2.' + ), + EnumMember( + 'TABLE_MEDIUM_SHADING_2_ACCENT_1', -177, + 'Medium Shading 2 Accent 1.' + ), + EnumMember( + 'TABLE_OF_AUTHORITIES', -45, 'Table of Authorities.' + ), + EnumMember( + 'TABLE_OF_FIGURES', -36, 'Table of Figures.' + ), + EnumMember( + 'TITLE', -63, 'Title.' + ), + EnumMember( + 'TOAHEADING', -47, 'TOA Heading.' + ), + EnumMember( + 'TOC_1', -20, 'TOC 1.' + ), + EnumMember( + 'TOC_2', -21, 'TOC 2.' + ), + EnumMember( + 'TOC_3', -22, 'TOC 3.' + ), + EnumMember( + 'TOC_4', -23, 'TOC 4.' + ), + EnumMember( + 'TOC_5', -24, 'TOC 5.' + ), + EnumMember( + 'TOC_6', -25, 'TOC 6.' + ), + EnumMember( + 'TOC_7', -26, 'TOC 7.' + ), + EnumMember( + 'TOC_8', -27, 'TOC 8.' + ), + EnumMember( + 'TOC_9', -28, 'TOC 9.' + ), + ) + + +class WD_STYLE_TYPE(XmlEnumeration): + """ + Specifies one of the four style types: paragraph, character, list, or + table. + + Example:: + + from docx import Document + from docx.enum.style import WD_STYLE_TYPE + + styles = Document().styles + assert styles[0].type == WD_STYLE_TYPE.PARAGRAPH + """ + + __ms_name__ = 'WdStyleType' + + __url__ = 'http://msdn.microsoft.com/en-us/library/office/ff196870.aspx' + + __members__ = ( + XmlMappedEnumMember( + 'CHARACTER', 2, 'character', 'Character style.' + ), + XmlMappedEnumMember( + 'LIST', 4, 'numbering', 'List style.' + ), + XmlMappedEnumMember( + 'PARAGRAPH', 1, 'paragraph', 'Paragraph style.' + ), + XmlMappedEnumMember( + 'TABLE', 3, 'table', 'Table style.' + ), + ) From 2c7321365f8344ff4392daf65df655a23abddf78 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 01:47:02 -0800 Subject: [PATCH 271/809] acpt: add scenarios for style access --- docx/styles/__init__.py | 0 docx/styles/style.py | 20 ++++++++ docx/styles/styles.py | 17 +++++++ features/doc-styles.feature | 24 ++++++++++ features/steps/styles.py | 43 +++++++++++++++++- .../test_files/sty-having-no-styles-part.docx | Bin 0 -> 8358 bytes .../test_files/sty-having-styles-part.docx | Bin 21362 -> 21573 bytes 7 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 docx/styles/__init__.py create mode 100644 docx/styles/style.py create mode 100644 docx/styles/styles.py create mode 100644 features/doc-styles.feature create mode 100644 features/steps/test_files/sty-having-no-styles-part.docx diff --git a/docx/styles/__init__.py b/docx/styles/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/docx/styles/style.py b/docx/styles/style.py new file mode 100644 index 000000000..fe84c8f35 --- /dev/null +++ b/docx/styles/style.py @@ -0,0 +1,20 @@ +# encoding: utf-8 + +""" +Style object hierarchy. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +from ..shared import ElementProxy + + +class BaseStyle(ElementProxy): + """ + Base class for the various types of style object, paragraph, character, + table, and numbering. + """ + + __slots__ = () diff --git a/docx/styles/styles.py b/docx/styles/styles.py new file mode 100644 index 000000000..e6b24d892 --- /dev/null +++ b/docx/styles/styles.py @@ -0,0 +1,17 @@ +# encoding: utf-8 + +""" +Styles object, container for all objects in the styles part. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + + +class Styles(object): + """ + A collection of |Style| objects defined in a document. Supports + ``len()``, iteration, and dictionary-style access by style id and style + UI name. + """ diff --git a/features/doc-styles.feature b/features/doc-styles.feature new file mode 100644 index 000000000..f72f33fa6 --- /dev/null +++ b/features/doc-styles.feature @@ -0,0 +1,24 @@ +Feature: Access document styles + In order to discover and manipulate document styles + As a developer using python-docx + I need a way to access document styles + + + @wip + Scenario Outline: Access document styles collection + Given a document having + Then I can access the document styles collection + And len(styles) is + + Examples: having styles or not + | styles-state | style-count | + | a styles part | 6 | + | no styles part | 4 | + + + @wip + Scenario: Access style in style collection + Given a document having a styles part + Then I can iterate over its styles + And I can access a style by style id + And I can access a style by its UI name diff --git a/features/steps/styles.py b/features/steps/styles.py index dc31dd80e..dda452b82 100644 --- a/features/steps/styles.py +++ b/features/steps/styles.py @@ -7,6 +7,8 @@ from behave import given, then, when from docx import Document +from docx.styles.styles import Styles +from docx.styles.style import BaseStyle from helpers import test_docx @@ -19,6 +21,12 @@ def given_a_document_having_a_styles_part(context): context.document = Document(docx_path) +@given('a document having no styles part') +def given_a_document_having_no_styles_part(context): + docx_path = test_docx('sty-having-no-styles-part') + context.document = Document(docx_path) + + # when ==================================================== @when('I get the styles part from the document') @@ -29,7 +37,40 @@ def when_get_styles_part_from_document(context): # then ===================================================== +@then('I can access a style by its UI name') +def then_I_can_access_a_style_by_its_UI_name(context): + styles = context.document.styles + style = styles['Default Paragraph Font'] + assert isinstance(style, BaseStyle) + + +@then('I can access a style by style id') +def then_I_can_access_a_style_by_style_id(context): + styles = context.document.styles + style = styles['DefaultParagraphFont'] + assert isinstance(style, BaseStyle) + + +@then('I can access the document styles collection') +def then_I_can_access_the_document_styles_collection(context): + document = context.document + styles = document.styles + assert isinstance(styles, Styles) + + +@then('I can iterate over its styles') +def then_I_can_iterate_over_its_styles(context): + styles = [s for s in context.document.styles] + assert len(styles) > 0 + assert all(isinstance(s, BaseStyle) for s in styles) + + +@then('len(styles) is {style_count_str}') +def then_len_styles_is_style_count(context, style_count_str): + assert len(context.document.styles) == int(style_count_str) + + @then('the styles part has the expected number of style definitions') def then_styles_part_has_expected_number_of_style_definitions(context): styles_part = context.styles_part - assert len(styles_part.styles) == 4 + assert len(styles_part.styles) == 6 diff --git a/features/steps/test_files/sty-having-no-styles-part.docx b/features/steps/test_files/sty-having-no-styles-part.docx new file mode 100644 index 0000000000000000000000000000000000000000..ce4d0b5ef1f8c8b1e0dc3efeec340a9752bae457 GIT binary patch literal 8358 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 literal 0 HcmV?d00001 diff --git a/features/steps/test_files/sty-having-styles-part.docx b/features/steps/test_files/sty-having-styles-part.docx index 65b40d9baa5561f7f891250948efde4ffcb992ca..06feeedd5e9a2e69ffe9d47e1af8e3e782aae7a3 100644 GIT binary patch delta 6904 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 delta 6665 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 Date: Fri, 19 Dec 2014 02:07:01 -0800 Subject: [PATCH 272/809] style: add Document.styles --- docx/api.py | 7 +++++++ docx/parts/document.py | 8 ++++++++ tests/test_api.py | 15 +++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/docx/api.py b/docx/api.py index 74eaea557..d3541bab9 100644 --- a/docx/api.py +++ b/docx/api.py @@ -159,6 +159,13 @@ def sections(self): """ return self._document_part.sections + @property + def styles(self): + """ + A |Styles| object providing access to the styles for this document. + """ + return self._document_part.styles + @lazyproperty def styles_part(self): """ diff --git a/docx/parts/document.py b/docx/parts/document.py index abf08b2d4..d8bc5c154 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -102,6 +102,14 @@ def sections(self): """ return Sections(self._element) + @property + def styles(self): + """ + A |Styles| object providing access to the styles in the styles part + of this document. + """ + raise NotImplementedError + @property def tables(self): """ diff --git a/tests/test_api.py b/tests/test_api.py index c22230b55..c03d63af3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -20,6 +20,7 @@ from docx.parts.styles import StylesPart from docx.section import Section from docx.shape import InlineShape +from docx.styles.styles import Styles from docx.table import Table from docx.text.paragraph import Paragraph from docx.text.run import Run @@ -138,6 +139,11 @@ def it_provides_access_to_the_core_properties(self, core_props_fixture): core_properties = document.core_properties assert core_properties is core_properties_ + def it_provides_access_to_its_styles(self, styles_fixture): + document, styles_ = styles_fixture + styles = document.styles + assert styles is styles_ + def it_provides_access_to_the_numbering_part(self, num_part_get_fixture): document, document_part_, numbering_part_ = num_part_get_fixture numbering_part = document.numbering_part @@ -249,6 +255,11 @@ def save_fixture(self, request, open_, package_): document = Document() return document, package_, file_ + @pytest.fixture + def styles_fixture(self, document, styles_): + document._document_part.styles = styles_ + return document, styles_ + @pytest.fixture def tables_fixture(self, document, tables_): return document, tables_ @@ -362,6 +373,10 @@ def section_(self, request): def start_type_(self, request): return instance_mock(request, int) + @pytest.fixture + def styles_(self, request): + return instance_mock(request, Styles) + @pytest.fixture def StylesPart_(self, request, styles_part_): StylesPart_ = class_mock(request, 'docx.api.StylesPart') From 5b25f14411aa521673210c2d13ecd033fd702f25 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 17:25:40 -0800 Subject: [PATCH 273/809] style: add DocumentPart.styles --- docx/parts/document.py | 10 +++++++++- tests/parts/test_document.py | 28 +++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docx/parts/document.py b/docx/parts/document.py index d8bc5c154..1e39435bd 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -108,7 +108,7 @@ def styles(self): A |Styles| object providing access to the styles in the styles part of this document. """ - raise NotImplementedError + return self._styles_part.styles @property def tables(self): @@ -119,6 +119,14 @@ def tables(self): """ return self.body.tables + @property + def _styles_part(self): + """ + Instance of |StylesPart| for this document. Creates an empty styles + part if one is not present. + """ + raise NotImplementedError + class _Body(BlockItemContainer): """ diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index c5f9a08cd..ceeb1f635 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -15,8 +15,10 @@ from docx.package import ImageParts, Package from docx.parts.document import _Body, DocumentPart, InlineShapes, Sections from docx.parts.image import ImagePart +from docx.parts.styles import StylesPart from docx.section import Section from docx.shape import InlineShape +from docx.styles.styles import Styles from docx.table import Table from docx.text.paragraph import Paragraph from docx.text.run import Run @@ -54,6 +56,11 @@ def it_provides_access_to_the_document_tables(self, tables_fixture): tables = document_part.tables assert tables is tables_ + def it_provides_access_to_the_document_styles(self, styles_fixture): + document_part, styles_ = styles_fixture + styles = document_part.styles + assert styles is styles_ + def it_provides_access_to_the_inline_shapes_in_the_document( self, inline_shapes_fixture): document, InlineShapes_, body_elm = inline_shapes_fixture @@ -162,11 +169,18 @@ def paragraphs_fixture(self, document_part_body_, body_, paragraphs_): return document_part, paragraphs_ @pytest.fixture - def sections_fixture(self, request, Sections_): + def sections_fixture(self, Sections_): document_elm = a_document().with_nsdecls().element document = DocumentPart(None, None, document_elm, None) return document, document_elm, Sections_ + @pytest.fixture + def styles_fixture(self, _styles_part_prop_, styles_part_, styles_): + document_part = DocumentPart(None, None, None, None) + _styles_part_prop_.return_value = styles_part_ + styles_part_.styles = styles_ + return document_part, styles_ + @pytest.fixture def tables_fixture(self, document_part_body_, body_, tables_): document_part = DocumentPart(None, None, None, None) @@ -275,6 +289,18 @@ def sectPr_(self, request): def start_type_(self, request): return instance_mock(request, int) + @pytest.fixture + def styles_(self, request): + return instance_mock(request, Styles) + + @pytest.fixture + def styles_part_(self, request): + return instance_mock(request, StylesPart) + + @pytest.fixture + def _styles_part_prop_(self, request): + return property_mock(request, DocumentPart, '_styles_part') + @pytest.fixture def table_(self, request): return instance_mock(request, Table) From adf878eafbc9b568dd23d02b45cadd47fdf1c003 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 18:07:22 -0800 Subject: [PATCH 274/809] style: add DocumentPart._styles_part * resequence Part.package property --- docx/opc/part.py | 14 ++++++------- docx/parts/document.py | 8 +++++++- docx/parts/styles.py | 8 ++++++++ tests/parts/test_document.py | 40 ++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/docx/opc/part.py b/docx/opc/part.py index 1196eee5c..928d3c183 100644 --- a/docx/opc/part.py +++ b/docx/opc/part.py @@ -89,6 +89,13 @@ def load_rel(self, reltype, target, rId, is_external=False): """ return self.rels.add_relationship(reltype, target, rId, is_external) + @property + def package(self): + """ + |OpcPackage| instance this part belongs to. + """ + return self._package + @property def partname(self): """ @@ -104,13 +111,6 @@ def partname(self, partname): raise TypeError(tmpl % type(partname).__name__) self._partname = partname - @property - def package(self): - """ - |OpcPackage| instance this part belongs to. - """ - return self._package - def part_related_by(self, reltype): """ Return part to which this part has a relationship of *reltype*. diff --git a/docx/parts/document.py b/docx/parts/document.py index 1e39435bd..f8d7c0b7a 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -17,6 +17,7 @@ from ..section import Section from ..shape import InlineShape from ..shared import lazyproperty, Parented +from .styles import StylesPart class DocumentPart(XmlPart): @@ -125,7 +126,12 @@ def _styles_part(self): Instance of |StylesPart| for this document. Creates an empty styles part if one is not present. """ - raise NotImplementedError + try: + return self.part_related_by(RT.STYLES) + except KeyError: + styles_part = StylesPart.default(self.package) + self.relate_to(styles_part, RT.STYLES) + return styles_part class _Body(BlockItemContainer): diff --git a/docx/parts/styles.py b/docx/parts/styles.py index d400ce50f..c4443ecdd 100644 --- a/docx/parts/styles.py +++ b/docx/parts/styles.py @@ -17,6 +17,14 @@ class StylesPart(XmlPart): Proxy for the styles.xml part containing style definitions for a document or glossary. """ + @classmethod + def default(cls, package): + """ + Return a newly created styles part, containing a default set of + elements. + """ + raise NotImplementedError + @classmethod def new(cls): """ diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index ceeb1f635..9a509d088 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -105,6 +105,23 @@ def it_knows_the_next_available_xml_id(self, next_id_fixture): document, expected_id = next_id_fixture assert document.next_id == expected_id + def it_provides_access_to_its_styles_part_to_help( + self, styles_part_get_fixture): + document_part, styles_part_ = styles_part_get_fixture + styles_part = document_part._styles_part + document_part.part_related_by.assert_called_once_with(RT.STYLES) + assert styles_part is styles_part_ + + def it_creates_default_styles_part_if_not_present_to_help( + self, styles_part_create_fixture): + document_part, StylesPart_, styles_part_ = styles_part_create_fixture + styles_part = document_part._styles_part + StylesPart_.default.assert_called_once_with(document_part.package) + document_part.relate_to.assert_called_once_with( + styles_part_, RT.STYLES + ) + assert styles_part is styles_part_ + # fixtures ------------------------------------------------------- @pytest.fixture @@ -181,6 +198,21 @@ def styles_fixture(self, _styles_part_prop_, styles_part_, styles_): styles_part_.styles = styles_ return document_part, styles_ + @pytest.fixture + def styles_part_create_fixture( + self, package_, part_related_by_, StylesPart_, styles_part_, + relate_to_): + document_part = DocumentPart(None, None, None, package_) + part_related_by_.side_effect = KeyError + StylesPart_.default.return_value = styles_part_ + return document_part, StylesPart_, styles_part_ + + @pytest.fixture + def styles_part_get_fixture(self, part_related_by_, styles_part_): + document_part = DocumentPart(None, None, None, None) + part_related_by_.return_value = styles_part_ + return document_part, styles_part_ + @pytest.fixture def tables_fixture(self, document_part_body_, body_, tables_): document_part = DocumentPart(None, None, None, None) @@ -257,6 +289,10 @@ def package_(self, request): def paragraphs_(self, request): return instance_mock(request, list) + @pytest.fixture + def part_related_by_(self, request): + return method_mock(request, DocumentPart, 'part_related_by') + @pytest.fixture def relate_to_(self, request, rId_): relate_to_ = method_mock(request, DocumentPart, 'relate_to') @@ -293,6 +329,10 @@ def start_type_(self, request): def styles_(self, request): return instance_mock(request, Styles) + @pytest.fixture + def StylesPart_(self, request): + return class_mock(request, 'docx.parts.document.StylesPart') + @pytest.fixture def styles_part_(self, request): return instance_mock(request, StylesPart) From f1ad6483fbeea7cd1addcae93c795b1e673577d9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 18:55:51 -0800 Subject: [PATCH 275/809] style: add StylesPart.styles Remove legacy _Styles class and tests --- docx/parts/styles.py | 19 +++----------- docx/styles/styles.py | 6 ++++- features/sty-get-styles-part.feature | 1 + tests/parts/test_styles.py | 39 +++++++--------------------- 4 files changed, 19 insertions(+), 46 deletions(-) diff --git a/docx/parts/styles.py b/docx/parts/styles.py index c4443ecdd..a0678c4b6 100644 --- a/docx/parts/styles.py +++ b/docx/parts/styles.py @@ -9,7 +9,7 @@ ) from ..opc.part import XmlPart -from ..shared import lazyproperty +from ..styles.styles import Styles class StylesPart(XmlPart): @@ -33,23 +33,10 @@ def new(cls): """ raise NotImplementedError - @lazyproperty + @property def styles(self): """ The |_Styles| instance containing the styles ( element proxies) for this styles part. """ - return _Styles(self._element) - - -class _Styles(object): - """ - Collection of |_Style| instances corresponding to the ```` - elements in a styles part. - """ - def __init__(self, styles_elm): - super(_Styles, self).__init__() - self._styles_elm = styles_elm - - def __len__(self): - return len(self._styles_elm.style_lst) + return Styles(self.element) diff --git a/docx/styles/styles.py b/docx/styles/styles.py index e6b24d892..fe7850096 100644 --- a/docx/styles/styles.py +++ b/docx/styles/styles.py @@ -8,10 +8,14 @@ absolute_import, division, print_function, unicode_literals ) +from ..shared import ElementProxy -class Styles(object): + +class Styles(ElementProxy): """ A collection of |Style| objects defined in a document. Supports ``len()``, iteration, and dictionary-style access by style id and style UI name. """ + + __slots__ = () diff --git a/features/sty-get-styles-part.feature b/features/sty-get-styles-part.feature index 27618568a..0b5ccbea4 100644 --- a/features/sty-get-styles-part.feature +++ b/features/sty-get-styles-part.feature @@ -3,6 +3,7 @@ Feature: Get the document styles part As a programmer using the advanced python-docx API I need access to the styles part of the document + @wip Scenario: Get an existing styles part from document Given a document having a styles part When I get the styles part from the document diff --git a/tests/parts/test_styles.py b/tests/parts/test_styles.py index 3294546c7..857a294b4 100644 --- a/tests/parts/test_styles.py +++ b/tests/parts/test_styles.py @@ -9,58 +9,39 @@ import pytest from docx.oxml.parts.styles import CT_Styles -from docx.parts.styles import StylesPart, _Styles +from docx.parts.styles import StylesPart +from docx.styles.styles import Styles -from ..oxml.unitdata.styles import a_style, a_styles from ..unitutil.mock import class_mock, instance_mock class DescribeStylesPart(object): - def it_provides_access_to_the_styles(self, styles_fixture): - styles_part, _Styles_, styles_elm_, styles_ = styles_fixture + def it_provides_access_to_its_styles(self, styles_fixture): + styles_part, Styles_, styles_ = styles_fixture styles = styles_part.styles - _Styles_.assert_called_once_with(styles_elm_) + Styles_.assert_called_once_with(styles_part.element) assert styles is styles_ # fixtures ------------------------------------------------------- @pytest.fixture - def styles_fixture(self, _Styles_, styles_elm_, styles_): + def styles_fixture(self, Styles_, styles_elm_, styles_): styles_part = StylesPart(None, None, styles_elm_, None) - return styles_part, _Styles_, styles_elm_, styles_ + return styles_part, Styles_, styles_ # fixture components --------------------------------------------- @pytest.fixture - def _Styles_(self, request, styles_): + def Styles_(self, request, styles_): return class_mock( - request, 'docx.parts.styles._Styles', return_value=styles_ + request, 'docx.parts.styles.Styles', return_value=styles_ ) @pytest.fixture def styles_(self, request): - return instance_mock(request, _Styles) + return instance_mock(request, Styles) @pytest.fixture def styles_elm_(self, request): return instance_mock(request, CT_Styles) - - -class Describe_Styles(object): - - def it_knows_how_many_styles_it_contains(self, len_fixture): - styles, style_count = len_fixture - assert len(styles) == style_count - - # fixtures ------------------------------------------------------- - - @pytest.fixture(params=[0, 1, 2, 3]) - def len_fixture(self, request): - style_count = request.param - styles_bldr = a_styles().with_nsdecls() - for idx in range(style_count): - styles_bldr.with_child(a_style()) - styles_elm = styles_bldr.element - styles = _Styles(styles_elm) - return styles, style_count From 587a8a319afd840f198ed34c4b66048e0f6a0ee2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 19:08:27 -0800 Subject: [PATCH 276/809] style: remove dead Document.styles_part and tests --- docx/api.py | 14 ---------- docx/parts/styles.py | 8 ------ features/steps/styles.py | 16 +---------- features/sty-get-styles-part.feature | 10 ------- tests/test_api.py | 42 +--------------------------- 5 files changed, 2 insertions(+), 88 deletions(-) delete mode 100644 features/sty-get-styles-part.feature diff --git a/docx/api.py b/docx/api.py index d3541bab9..3adc3622e 100644 --- a/docx/api.py +++ b/docx/api.py @@ -15,7 +15,6 @@ from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT from docx.package import Package from docx.parts.numbering import NumberingPart -from docx.parts.styles import StylesPart from docx.shared import lazyproperty @@ -166,19 +165,6 @@ def styles(self): """ return self._document_part.styles - @lazyproperty - def styles_part(self): - """ - Instance of |StylesPart| for this document. Creates an empty styles - part if one is not present. - """ - try: - return self._document_part.part_related_by(RT.STYLES) - except KeyError: - styles_part = StylesPart.new() - self._document_part.relate_to(styles_part, RT.STYLES) - return styles_part - @property def tables(self): """ diff --git a/docx/parts/styles.py b/docx/parts/styles.py index a0678c4b6..19d45e8f0 100644 --- a/docx/parts/styles.py +++ b/docx/parts/styles.py @@ -25,14 +25,6 @@ def default(cls, package): """ raise NotImplementedError - @classmethod - def new(cls): - """ - Return newly created empty styles part, containing only the root - ```` element. - """ - raise NotImplementedError - @property def styles(self): """ diff --git a/features/steps/styles.py b/features/steps/styles.py index dda452b82..652ae132b 100644 --- a/features/steps/styles.py +++ b/features/steps/styles.py @@ -4,7 +4,7 @@ Step implementations for styles-related features """ -from behave import given, then, when +from behave import given, then from docx import Document from docx.styles.styles import Styles @@ -27,14 +27,6 @@ def given_a_document_having_no_styles_part(context): context.document = Document(docx_path) -# when ==================================================== - -@when('I get the styles part from the document') -def when_get_styles_part_from_document(context): - document = context.document - context.styles_part = document.styles_part - - # then ===================================================== @then('I can access a style by its UI name') @@ -68,9 +60,3 @@ def then_I_can_iterate_over_its_styles(context): @then('len(styles) is {style_count_str}') def then_len_styles_is_style_count(context, style_count_str): assert len(context.document.styles) == int(style_count_str) - - -@then('the styles part has the expected number of style definitions') -def then_styles_part_has_expected_number_of_style_definitions(context): - styles_part = context.styles_part - assert len(styles_part.styles) == 6 diff --git a/features/sty-get-styles-part.feature b/features/sty-get-styles-part.feature deleted file mode 100644 index 0b5ccbea4..000000000 --- a/features/sty-get-styles-part.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: Get the document styles part - In order to query and modify styles - As a programmer using the advanced python-docx API - I need access to the styles part of the document - - @wip - Scenario: Get an existing styles part from document - Given a document having a styles part - When I get the styles part from the document - Then the styles part has the expected number of style definitions diff --git a/tests/test_api.py b/tests/test_api.py index c03d63af3..fd375001c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -17,7 +17,6 @@ from docx.package import Package from docx.parts.document import DocumentPart, InlineShapes from docx.parts.numbering import NumberingPart -from docx.parts.styles import StylesPart from docx.section import Section from docx.shape import InlineShape from docx.styles.styles import Styles @@ -134,7 +133,7 @@ def it_can_save_the_package(self, save_fixture): document.save(file_) package_.save.assert_called_once_with(file_) - def it_provides_access_to_the_core_properties(self, core_props_fixture): + def it_provides_access_to_its_core_properties(self, core_props_fixture): document, core_properties_ = core_props_fixture core_properties = document.core_properties assert core_properties is core_properties_ @@ -162,24 +161,6 @@ def it_creates_numbering_part_on_first_access_if_not_present( ) assert numbering_part is numbering_part_ - def it_provides_access_to_the_styles_part(self, styles_part_get_fixture): - document, document_part_, styles_part_ = styles_part_get_fixture - styles_part = document.styles_part - document_part_.part_related_by.assert_called_once_with(RT.STYLES) - assert styles_part is styles_part_ - - def it_creates_styles_part_on_first_access_if_not_present( - self, styles_part_create_fixture): - document, StylesPart_, document_part_, styles_part_ = ( - styles_part_create_fixture - ) - styles_part = document.styles_part - StylesPart_.new.assert_called_once_with() - document_part_.relate_to.assert_called_once_with( - styles_part_, RT.STYLES - ) - assert styles_part is styles_part_ - # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -377,27 +358,6 @@ def start_type_(self, request): def styles_(self, request): return instance_mock(request, Styles) - @pytest.fixture - def StylesPart_(self, request, styles_part_): - StylesPart_ = class_mock(request, 'docx.api.StylesPart') - StylesPart_.new.return_value = styles_part_ - return StylesPart_ - - @pytest.fixture - def styles_part_(self, request): - return instance_mock(request, StylesPart) - - @pytest.fixture - def styles_part_create_fixture( - self, document, StylesPart_, document_part_, styles_part_): - document_part_.part_related_by.side_effect = KeyError - return document, StylesPart_, document_part_, styles_part_ - - @pytest.fixture - def styles_part_get_fixture(self, document, document_part_, styles_part_): - document_part_.part_related_by.return_value = styles_part_ - return document, document_part_, styles_part_ - @pytest.fixture def table_(self, request): return instance_mock(request, Table, style=None) From bb948db06f0d2a43bff03dc60bbc08c36bb115ae Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 22:03:15 -0800 Subject: [PATCH 277/809] style: add Styles.__len__() --- docx/styles/styles.py | 3 +++ tests/styles/__init__.py | 0 tests/styles/test_styles.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 tests/styles/__init__.py create mode 100644 tests/styles/test_styles.py diff --git a/docx/styles/styles.py b/docx/styles/styles.py index fe7850096..78bf55bfe 100644 --- a/docx/styles/styles.py +++ b/docx/styles/styles.py @@ -19,3 +19,6 @@ class Styles(ElementProxy): """ __slots__ = () + + def __len__(self): + return len(self._element.style_lst) diff --git a/tests/styles/__init__.py b/tests/styles/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/styles/test_styles.py b/tests/styles/test_styles.py new file mode 100644 index 000000000..a211c758f --- /dev/null +++ b/tests/styles/test_styles.py @@ -0,0 +1,35 @@ +# encoding: utf-8 + +""" +Test suite for the docx.styles module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.styles.styles import Styles + +from ..unitutil.cxml import element + + +class DescribeStyles(object): + + def it_knows_its_length(self, len_fixture): + styles, expected_value = len_fixture + assert len(styles) == expected_value + + # fixture -------------------------------------------------------- + + @pytest.fixture(params=[ + ('w:styles', 0), + ('w:styles/w:style', 1), + ('w:styles/(w:style,w:style)', 2), + ('w:styles/(w:style,w:style,w:style)', 3), + ]) + def len_fixture(self, request): + styles_cxml, expected_value = request.param + styles = Styles(element(styles_cxml)) + return styles, expected_value From 371c94f092e186349749f63a543018740c9894e4 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 22:39:47 -0800 Subject: [PATCH 278/809] style: add StylesPart.default() --- docx/parts/styles.py | 23 +++- docx/templates/default-styles.xml | 190 ++++++++++++++++++++++++++++++ features/doc-styles.feature | 1 - tests/parts/test_styles.py | 11 ++ 4 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 docx/templates/default-styles.xml diff --git a/docx/parts/styles.py b/docx/parts/styles.py index 19d45e8f0..00c7cb3c3 100644 --- a/docx/parts/styles.py +++ b/docx/parts/styles.py @@ -8,7 +8,12 @@ absolute_import, division, print_function, unicode_literals ) +import os + +from ..opc.constants import CONTENT_TYPE as CT +from ..opc.packuri import PackURI from ..opc.part import XmlPart +from ..oxml import parse_xml from ..styles.styles import Styles @@ -23,7 +28,10 @@ def default(cls, package): Return a newly created styles part, containing a default set of elements. """ - raise NotImplementedError + partname = PackURI('/word/styles.xml') + content_type = CT.WML_STYLES + element = parse_xml(cls._default_styles_xml()) + return cls(partname, content_type, element, package) @property def styles(self): @@ -32,3 +40,16 @@ def styles(self): proxies) for this styles part. """ return Styles(self.element) + + @classmethod + def _default_styles_xml(cls): + """ + Return a bytestream containing XML for a default styles part. + """ + path = os.path.join( + os.path.split(__file__)[0], '..', 'templates', + 'default-styles.xml' + ) + with open(path, 'rb') as f: + xml_bytes = f.read() + return xml_bytes diff --git a/docx/templates/default-styles.xml b/docx/templates/default-styles.xml new file mode 100644 index 000000000..b8b97bc70 --- /dev/null +++ b/docx/templates/default-styles.xml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/features/doc-styles.feature b/features/doc-styles.feature index f72f33fa6..47c232e48 100644 --- a/features/doc-styles.feature +++ b/features/doc-styles.feature @@ -4,7 +4,6 @@ Feature: Access document styles I need a way to access document styles - @wip Scenario Outline: Access document styles collection Given a document having Then I can access the document styles collection diff --git a/tests/parts/test_styles.py b/tests/parts/test_styles.py index 857a294b4..5e5f202be 100644 --- a/tests/parts/test_styles.py +++ b/tests/parts/test_styles.py @@ -8,6 +8,8 @@ import pytest +from docx.opc.constants import CONTENT_TYPE as CT +from docx.opc.package import OpcPackage from docx.oxml.parts.styles import CT_Styles from docx.parts.styles import StylesPart from docx.styles.styles import Styles @@ -23,6 +25,15 @@ def it_provides_access_to_its_styles(self, styles_fixture): Styles_.assert_called_once_with(styles_part.element) assert styles is styles_ + def it_can_construct_a_default_styles_part_to_help(self): + package = OpcPackage() + styles_part = StylesPart.default(package) + assert isinstance(styles_part, StylesPart) + assert styles_part.partname == '/word/styles.xml' + assert styles_part.content_type == CT.WML_STYLES + assert styles_part.package is package + assert len(styles_part.element) == 6 + # fixtures ------------------------------------------------------- @pytest.fixture From d76d47c4255849a5b4115f998cfb36958c9caa1f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 23:56:08 -0800 Subject: [PATCH 279/809] style: add Styles.__iter__() --- docx/styles/style.py | 8 ++++++++ docx/styles/styles.py | 4 ++++ tests/styles/test_styles.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/docx/styles/style.py b/docx/styles/style.py index fe84c8f35..f96289317 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -11,6 +11,14 @@ from ..shared import ElementProxy +def StyleFactory(style_elm): + """ + Return a style object of the appropriate |_BaseStyle| subclass, according + to it style type. + """ + raise NotImplementedError + + class BaseStyle(ElementProxy): """ Base class for the various types of style object, paragraph, character, diff --git a/docx/styles/styles.py b/docx/styles/styles.py index 78bf55bfe..5f452902a 100644 --- a/docx/styles/styles.py +++ b/docx/styles/styles.py @@ -9,6 +9,7 @@ ) from ..shared import ElementProxy +from .style import StyleFactory class Styles(ElementProxy): @@ -20,5 +21,8 @@ class Styles(ElementProxy): __slots__ = () + def __iter__(self): + return (StyleFactory(style) for style in self._element.style_lst) + def __len__(self): return len(self._element.style_lst) diff --git a/tests/styles/test_styles.py b/tests/styles/test_styles.py index a211c758f..cc429ef71 100644 --- a/tests/styles/test_styles.py +++ b/tests/styles/test_styles.py @@ -10,9 +10,11 @@ import pytest +from docx.styles.style import BaseStyle from docx.styles.styles import Styles from ..unitutil.cxml import element +from ..unitutil.mock import call, function_mock, instance_mock class DescribeStyles(object): @@ -21,8 +23,33 @@ def it_knows_its_length(self, len_fixture): styles, expected_value = len_fixture assert len(styles) == expected_value + def it_can_iterate_over_its_styles(self, iter_fixture): + styles, expected_count, style_, StyleFactory_, expected_calls = ( + iter_fixture + ) + count = 0 + for style in styles: + assert style is style_ + count += 1 + assert count == expected_count + assert StyleFactory_.call_args_list == expected_calls + # fixture -------------------------------------------------------- + @pytest.fixture(params=[ + ('w:styles', 0), + ('w:styles/w:style', 1), + ('w:styles/(w:style,w:style)', 2), + ('w:styles/(w:style,w:style,w:style)', 3), + ]) + def iter_fixture(self, request, StyleFactory_, style_): + styles_cxml, expected_count = request.param + styles_elm = element(styles_cxml) + styles = Styles(styles_elm) + expected_calls = [call(style_elm) for style_elm in styles_elm] + StyleFactory_.return_value = style_ + return styles, expected_count, style_, StyleFactory_, expected_calls + @pytest.fixture(params=[ ('w:styles', 0), ('w:styles/w:style', 1), @@ -33,3 +60,13 @@ def len_fixture(self, request): styles_cxml, expected_value = request.param styles = Styles(element(styles_cxml)) return styles, expected_value + + # fixture components --------------------------------------------- + + @pytest.fixture + def style_(self, request): + return instance_mock(request, BaseStyle) + + @pytest.fixture + def StyleFactory_(self, request): + return function_mock(request, 'docx.styles.styles.StyleFactory') From 11f5f839c6c870a6e1442f848dee7ae7455ab205 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 24 Dec 2014 02:40:19 -0800 Subject: [PATCH 280/809] style: add StyleFactory() * refactor xmlchemy declarations in CT_Style --- docx/oxml/parts/styles.py | 18 ++++++-- docx/styles/style.py | 46 +++++++++++++++++-- tests/styles/test_style.py | 92 +++++++++++++++++++++++++++++++++++++ tests/styles/test_styles.py | 2 +- 4 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 tests/styles/test_style.py diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index 7fea25a01..6abbee45b 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -4,16 +4,26 @@ Custom element classes related to the styles part """ -from ..xmlchemy import BaseOxmlElement, ZeroOrMore, ZeroOrOne +from ...enum.style import WD_STYLE_TYPE +from ..xmlchemy import ( + BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne +) class CT_Style(BaseOxmlElement): """ A ```` element, representing a style definition """ - pPr = ZeroOrOne('w:pPr', successors=( - 'w:rPr', 'w:tblPr', 'w:trPr', 'w:tcPr', 'w:tblStylePr' - )) + _tag_seq = ( + 'w:name', 'w:aliases', 'w:basedOn', 'w:next', 'w:link', + 'w:autoRedefine', 'w:hidden', 'w:uiPriority', 'w:semiHidden', + 'w:unhideWhenUsed', 'w:qFormat', 'w:locked', 'w:personal', + 'w:personalCompose', 'w:personalReply', 'w:rsid', 'w:pPr', 'w:rPr', + 'w:tblPr', 'w:trPr', 'w:tcPr', 'w:tblStylePr' + ) + pPr = ZeroOrOne('w:pPr', successors=_tag_seq[17:]) + type = OptionalAttribute('w:type', WD_STYLE_TYPE) + del _tag_seq class CT_Styles(BaseOxmlElement): diff --git a/docx/styles/style.py b/docx/styles/style.py index f96289317..bcfee051b 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -8,15 +8,23 @@ absolute_import, division, print_function, unicode_literals ) +from ..enum.style import WD_STYLE_TYPE from ..shared import ElementProxy def StyleFactory(style_elm): """ - Return a style object of the appropriate |_BaseStyle| subclass, according - to it style type. + Return a style object of the appropriate |BaseStyle| subclass, according + to the type of *style_elm*. """ - raise NotImplementedError + style_cls = { + WD_STYLE_TYPE.PARAGRAPH: _ParagraphStyle, + WD_STYLE_TYPE.CHARACTER: _CharacterStyle, + WD_STYLE_TYPE.TABLE: _TableStyle, + WD_STYLE_TYPE.LIST: _NumberingStyle + }[style_elm.type] + + return style_cls(style_elm) class BaseStyle(ElementProxy): @@ -26,3 +34,35 @@ class BaseStyle(ElementProxy): """ __slots__ = () + + +class _CharacterStyle(BaseStyle): + """ + A character style. + """ + + __slots__ = () + + +class _ParagraphStyle(_CharacterStyle): + """ + A paragraph style. + """ + + __slots__ = () + + +class _TableStyle(_ParagraphStyle): + """ + A table style. + """ + + __slots__ = () + + +class _NumberingStyle(BaseStyle): + """ + A numbering style. + """ + + __slots__ = () diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py new file mode 100644 index 000000000..76e5ed95f --- /dev/null +++ b/tests/styles/test_style.py @@ -0,0 +1,92 @@ +# encoding: utf-8 + +""" +Test suite for the docx.styles.style module +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.styles.style import ( + _CharacterStyle, _ParagraphStyle, _NumberingStyle, StyleFactory, + _TableStyle +) + +from ..unitutil.cxml import element +from ..unitutil.mock import class_mock, instance_mock + + +class DescribeStyleFactory(object): + + def it_constructs_the_right_type_of_style(self, factory_fixture): + style_elm, StyleCls_, style_ = factory_fixture + style = StyleFactory(style_elm) + StyleCls_.assert_called_once_with(style_elm) + assert style is style_ + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=['paragraph', 'character', 'table', 'numbering']) + def factory_fixture( + self, request, paragraph_style_, _ParagraphStyle_, + character_style_, _CharacterStyle_, table_style_, _TableStyle_, + numbering_style_, _NumberingStyle_): + type_attr_val = request.param + StyleCls_, style_mock = { + 'paragraph': (_ParagraphStyle_, paragraph_style_), + 'character': (_CharacterStyle_, character_style_), + 'table': (_TableStyle_, table_style_), + 'numbering': (_NumberingStyle_, numbering_style_), + }[request.param] + style_cxml = 'w:style{w:type=%s}' % type_attr_val + style_elm = element(style_cxml) + return style_elm, StyleCls_, style_mock + + # fixture components ----------------------------------- + + @pytest.fixture + def _ParagraphStyle_(self, request, paragraph_style_): + return class_mock( + request, 'docx.styles.style._ParagraphStyle', + return_value=paragraph_style_ + ) + + @pytest.fixture + def paragraph_style_(self, request): + return instance_mock(request, _ParagraphStyle) + + @pytest.fixture + def _CharacterStyle_(self, request, character_style_): + return class_mock( + request, 'docx.styles.style._CharacterStyle', + return_value=character_style_ + ) + + @pytest.fixture + def character_style_(self, request): + return instance_mock(request, _CharacterStyle) + + @pytest.fixture + def _TableStyle_(self, request, table_style_): + return class_mock( + request, 'docx.styles.style._TableStyle', + return_value=table_style_ + ) + + @pytest.fixture + def table_style_(self, request): + return instance_mock(request, _TableStyle) + + @pytest.fixture + def _NumberingStyle_(self, request, numbering_style_): + return class_mock( + request, 'docx.styles.style._NumberingStyle', + return_value=numbering_style_ + ) + + @pytest.fixture + def numbering_style_(self, request): + return instance_mock(request, _NumberingStyle) diff --git a/tests/styles/test_styles.py b/tests/styles/test_styles.py index cc429ef71..3ba3e256f 100644 --- a/tests/styles/test_styles.py +++ b/tests/styles/test_styles.py @@ -1,7 +1,7 @@ # encoding: utf-8 """ -Test suite for the docx.styles module +Test suite for the docx.styles.styles module """ from __future__ import ( From 7fc1c2cf08932c723adb9a21eb038395c186d77b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 19 Dec 2014 23:58:01 -0800 Subject: [PATCH 281/809] style: add Styles.__getitem__() --- docx/oxml/parts/styles.py | 19 +++++++++++---- docx/styles/styles.py | 10 ++++++++ features/doc-styles.feature | 1 - tests/styles/test_styles.py | 48 +++++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index 6abbee45b..80a8a2921 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -33,13 +33,24 @@ class CT_Styles(BaseOxmlElement): """ style = ZeroOrMore('w:style', successors=()) - def style_having_styleId(self, styleId): + def get_by_id(self, styleId): """ Return the ```` child element having ``styleId`` attribute - matching *styleId*. + matching *styleId*, or |None| if not found. """ - xpath = './w:style[@w:styleId="%s"]' % styleId + xpath = 'w:style[@w:styleId="%s"]' % styleId try: return self.xpath(xpath)[0] except IndexError: - raise KeyError('no element with styleId %s' % styleId) + return None + + def get_by_name(self, name): + """ + Return the ```` child element having ```` child + element with value *name*, or |None| if not found. + """ + xpath = 'w:style[w:name/@w:val="%s"]' % name + try: + return self.xpath(xpath)[0] + except IndexError: + return None diff --git a/docx/styles/styles.py b/docx/styles/styles.py index 5f452902a..1f5805756 100644 --- a/docx/styles/styles.py +++ b/docx/styles/styles.py @@ -21,6 +21,16 @@ class Styles(ElementProxy): __slots__ = () + def __getitem__(self, key): + """ + Enables dictionary-style access by style id or UI name. + """ + for get in (self._element.get_by_id, self._element.get_by_name): + style_elm = get(key) + if style_elm is not None: + return StyleFactory(style_elm) + raise KeyError("no style with id or name '%s'" % key) + def __iter__(self): return (StyleFactory(style) for style in self._element.style_lst) diff --git a/features/doc-styles.feature b/features/doc-styles.feature index 47c232e48..dafe3bce7 100644 --- a/features/doc-styles.feature +++ b/features/doc-styles.feature @@ -15,7 +15,6 @@ Feature: Access document styles | no styles part | 4 | - @wip Scenario: Access style in style collection Given a document having a styles part Then I can iterate over its styles diff --git a/tests/styles/test_styles.py b/tests/styles/test_styles.py index 3ba3e256f..2d8c8f977 100644 --- a/tests/styles/test_styles.py +++ b/tests/styles/test_styles.py @@ -34,8 +34,56 @@ def it_can_iterate_over_its_styles(self, iter_fixture): assert count == expected_count assert StyleFactory_.call_args_list == expected_calls + def it_can_get_a_style_by_id(self, get_by_id_fixture): + styles, key, expected_element = get_by_id_fixture + style = styles[key] + assert style._element is expected_element + + def it_can_get_a_style_by_name(self, get_by_name_fixture): + styles, key, expected_element = get_by_name_fixture + style = styles[key] + assert style._element is expected_element + + def it_raises_on_style_not_found(self, get_raises_fixture): + styles, key = get_raises_fixture + with pytest.raises(KeyError): + styles[key] + # fixture -------------------------------------------------------- + @pytest.fixture(params=[ + ('w:styles/(w:style{%s,w:styleId=Foobar},w:style,w:style)', 0), + ('w:styles/(w:style,w:style{%s,w:styleId=Foobar},w:style)', 1), + ('w:styles/(w:style,w:style,w:style{%s,w:styleId=Foobar})', 2), + ]) + def get_by_id_fixture(self, request): + styles_cxml_tmpl, style_idx = request.param + styles_cxml = styles_cxml_tmpl % 'w:type=paragraph' + styles = Styles(element(styles_cxml)) + expected_element = styles._element[style_idx] + return styles, 'Foobar', expected_element + + @pytest.fixture(params=[ + ('w:styles/(w:style%s/w:name{w:val=foo},w:style,w:style)', 0), + ('w:styles/(w:style,w:style%s/w:name{w:val=foo},w:style)', 1), + ('w:styles/(w:style,w:style,w:style%s/w:name{w:val=foo})', 2), + ]) + def get_by_name_fixture(self, request): + styles_cxml_tmpl, style_idx = request.param + styles_cxml = styles_cxml_tmpl % '{w:type=character}' + styles = Styles(element(styles_cxml)) + expected_element = styles._element[style_idx] + return styles, 'foo', expected_element + + @pytest.fixture(params=[ + ('w:styles/(w:style,w:style/w:name{w:val=foo},w:style)'), + ('w:styles/(w:style{w:styleId=foo},w:style,w:style)'), + ]) + def get_raises_fixture(self, request): + styles_cxml = request.param + styles = Styles(element(styles_cxml)) + return styles, 'bar' + @pytest.fixture(params=[ ('w:styles', 0), ('w:styles/w:style', 1), From 6f0bc1ff4964ecd8e31f5a226ccb772b7f9abdd9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 01:27:58 -0800 Subject: [PATCH 282/809] acpt: add scenarios for Style.style_id --- features/steps/styles.py | 26 +++++++++++++++++++++++++- features/sty-style-props.feature | 17 +++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 features/sty-style-props.feature diff --git a/features/steps/styles.py b/features/steps/styles.py index 652ae132b..9d653bc3e 100644 --- a/features/steps/styles.py +++ b/features/steps/styles.py @@ -4,7 +4,7 @@ Step implementations for styles-related features """ -from behave import given, then +from behave import given, then, when from docx import Document from docx.styles.styles import Styles @@ -27,6 +27,20 @@ def given_a_document_having_no_styles_part(context): context.document = Document(docx_path) +@given('a style having a known style id') +def given_a_style_having_a_known_style_id(context): + docx_path = test_docx('sty-having-styles-part') + document = Document(docx_path) + context.style = document.styles['Normal'] + + +# when ===================================================== + +@when('I assign a new value to style.style_id') +def when_I_assign_a_new_value_to_style_style_id(context): + context.style.style_id = 'Foo42' + + # then ===================================================== @then('I can access a style by its UI name') @@ -60,3 +74,13 @@ def then_I_can_iterate_over_its_styles(context): @then('len(styles) is {style_count_str}') def then_len_styles_is_style_count(context, style_count_str): assert len(context.document.styles) == int(style_count_str) + + +@then('style.style_id is the {which} style id') +def then_style_style_id_is_the_which_style_id(context, which): + expected_style_id = { + 'known': 'Normal', + 'new': 'Foo42', + }[which] + style = context.style + assert style.style_id == expected_style_id diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature new file mode 100644 index 000000000..76d142fb6 --- /dev/null +++ b/features/sty-style-props.feature @@ -0,0 +1,17 @@ +Feature: Get and set style properties + In order to adjust styles to suit my needs + As a developer using python-docx + I need a set of read/write style properties + + + @wip + Scenario: Get style id + Given a style having a known style id + Then style.style_id is the known style id + + + @wip + Scenario: Set style id + Given a style having a known style id + When I assign a new value to style.style_id + Then style.style_id is the new style id From 4d196dd68644c748d6f96824aaec4ef2d76829ec Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 01:48:41 -0800 Subject: [PATCH 283/809] style: add _Style.style_id getter --- docx/oxml/parts/styles.py | 2 ++ docx/styles/style.py | 7 +++++++ features/sty-style-props.feature | 1 - tests/styles/test_style.py | 22 ++++++++++++++++++++-- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index 80a8a2921..3c6fee6ed 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -5,6 +5,7 @@ """ from ...enum.style import WD_STYLE_TYPE +from ..simpletypes import ST_String from ..xmlchemy import ( BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne ) @@ -23,6 +24,7 @@ class CT_Style(BaseOxmlElement): ) pPr = ZeroOrOne('w:pPr', successors=_tag_seq[17:]) type = OptionalAttribute('w:type', WD_STYLE_TYPE) + styleId = OptionalAttribute('w:styleId', ST_String) del _tag_seq diff --git a/docx/styles/style.py b/docx/styles/style.py index bcfee051b..d51241ce0 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -35,6 +35,13 @@ class BaseStyle(ElementProxy): __slots__ = () + @property + def style_id(self): + """ + The unique key name (string) for this style. + """ + return self._element.styleId + class _CharacterStyle(BaseStyle): """ diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index 76d142fb6..e3e087b3c 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -4,7 +4,6 @@ Feature: Get and set style properties I need a set of read/write style properties - @wip Scenario: Get style id Given a style having a known style id Then style.style_id is the known style id diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py index 76e5ed95f..8f5b01c0a 100644 --- a/tests/styles/test_style.py +++ b/tests/styles/test_style.py @@ -11,8 +11,8 @@ import pytest from docx.styles.style import ( - _CharacterStyle, _ParagraphStyle, _NumberingStyle, StyleFactory, - _TableStyle + BaseStyle, _CharacterStyle, _ParagraphStyle, _NumberingStyle, + StyleFactory, _TableStyle ) from ..unitutil.cxml import element @@ -90,3 +90,21 @@ def _NumberingStyle_(self, request, numbering_style_): @pytest.fixture def numbering_style_(self, request): return instance_mock(request, _NumberingStyle) + + +class DescribeBaseStyle(object): + + def it_knows_its_style_id(self, id_get_fixture): + style, expected_value = id_get_fixture + assert style.style_id == expected_value + + # fixture -------------------------------------------------------- + + @pytest.fixture(params=[ + ('w:style', None), + ('w:style{w:styleId=Foobar}', 'Foobar'), + ]) + def id_get_fixture(self, request): + style_cxml, expected_value = request.param + style = BaseStyle(element(style_cxml)) + return style, expected_value From fbc93af1f0b5d1d9911809d85a7296c47e322543 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 01:57:36 -0800 Subject: [PATCH 284/809] style: add _Style.style_id setter --- docx/styles/style.py | 4 ++++ features/sty-style-props.feature | 1 - tests/styles/test_style.py | 19 ++++++++++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docx/styles/style.py b/docx/styles/style.py index d51241ce0..8a721fa0b 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -42,6 +42,10 @@ def style_id(self): """ return self._element.styleId + @style_id.setter + def style_id(self, value): + self._element.styleId = value + class _CharacterStyle(BaseStyle): """ diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index e3e087b3c..eb7498556 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -9,7 +9,6 @@ Feature: Get and set style properties Then style.style_id is the known style id - @wip Scenario: Set style id Given a style having a known style id When I assign a new value to style.style_id diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py index 8f5b01c0a..6beb672ce 100644 --- a/tests/styles/test_style.py +++ b/tests/styles/test_style.py @@ -15,7 +15,7 @@ StyleFactory, _TableStyle ) -from ..unitutil.cxml import element +from ..unitutil.cxml import element, xml from ..unitutil.mock import class_mock, instance_mock @@ -98,6 +98,11 @@ def it_knows_its_style_id(self, id_get_fixture): style, expected_value = id_get_fixture assert style.style_id == expected_value + def it_can_change_its_style_id(self, id_set_fixture): + style, new_value, expected_xml = id_set_fixture + style.style_id = new_value + assert style._element.xml == expected_xml + # fixture -------------------------------------------------------- @pytest.fixture(params=[ @@ -108,3 +113,15 @@ def id_get_fixture(self, request): style_cxml, expected_value = request.param style = BaseStyle(element(style_cxml)) return style, expected_value + + @pytest.fixture(params=[ + ('w:style', 'Foo', 'w:style{w:styleId=Foo}'), + ('w:style{w:styleId=Foo}', 'Bar', 'w:style{w:styleId=Bar}'), + ('w:style{w:styleId=Bar}', None, 'w:style'), + ('w:style', None, 'w:style'), + ]) + def id_set_fixture(self, request): + style_cxml, new_value, expected_style_cxml = request.param + style = BaseStyle(element(style_cxml)) + expected_xml = xml(expected_style_cxml) + return style, new_value, expected_xml From c4c978aa08fe12a98b4eac576adb8e0ec754f0c9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 02:13:18 -0800 Subject: [PATCH 285/809] acpt: add scenario for _Style.type --- features/steps/styles.py | 14 ++++++++++++++ features/sty-style-props.feature | 6 ++++++ 2 files changed, 20 insertions(+) diff --git a/features/steps/styles.py b/features/steps/styles.py index 9d653bc3e..adb18ea7e 100644 --- a/features/steps/styles.py +++ b/features/steps/styles.py @@ -7,6 +7,7 @@ from behave import given, then, when from docx import Document +from docx.enum.style import WD_STYLE_TYPE from docx.styles.styles import Styles from docx.styles.style import BaseStyle @@ -34,6 +35,13 @@ def given_a_style_having_a_known_style_id(context): context.style = document.styles['Normal'] +@given('a style having a known type') +def given_a_style_having_a_known_type(context): + docx_path = test_docx('sty-having-styles-part') + document = Document(docx_path) + context.style = document.styles['Normal'] + + # when ===================================================== @when('I assign a new value to style.style_id') @@ -84,3 +92,9 @@ def then_style_style_id_is_the_which_style_id(context, which): }[which] style = context.style assert style.style_id == expected_style_id + + +@then('style.type is the known type') +def then_style_type_is_the_known_type(context): + style = context.style + assert style.type == WD_STYLE_TYPE.PARAGRAPH diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index eb7498556..b4da5c054 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -13,3 +13,9 @@ Feature: Get and set style properties Given a style having a known style id When I assign a new value to style.style_id Then style.style_id is the new style id + + + @wip + Scenario: Get style type + Given a style having a known type + Then style.type is the known type From ba9c2a29eee2ad06ec8925d69c8cc7c504db7f2b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 02:32:01 -0800 Subject: [PATCH 286/809] style: add _Style.type getter --- docx/styles/style.py | 11 +++++++++++ features/sty-style-props.feature | 1 - tests/styles/test_style.py | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docx/styles/style.py b/docx/styles/style.py index 8a721fa0b..bb857e05d 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -46,6 +46,17 @@ def style_id(self): def style_id(self, value): self._element.styleId = value + @property + def type(self): + """ + Member of :ref:`WdStyleType` corresponding to the type of this style, + e.g. ``WD_STYLE_TYPE.PARAGRAPH`. + """ + type = self._element.type + if type is None: + return WD_STYLE_TYPE.PARAGRAPH + return type + class _CharacterStyle(BaseStyle): """ diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index b4da5c054..84952efef 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -15,7 +15,6 @@ Feature: Get and set style properties Then style.style_id is the new style id - @wip Scenario: Get style type Given a style having a known type Then style.type is the known type diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py index 6beb672ce..3e9ef350d 100644 --- a/tests/styles/test_style.py +++ b/tests/styles/test_style.py @@ -10,6 +10,7 @@ import pytest +from docx.enum.style import WD_STYLE_TYPE from docx.styles.style import ( BaseStyle, _CharacterStyle, _ParagraphStyle, _NumberingStyle, StyleFactory, _TableStyle @@ -103,6 +104,10 @@ def it_can_change_its_style_id(self, id_set_fixture): style.style_id = new_value assert style._element.xml == expected_xml + def it_knows_its_type(self, type_get_fixture): + style, expected_value = type_get_fixture + assert style.type == expected_value + # fixture -------------------------------------------------------- @pytest.fixture(params=[ @@ -125,3 +130,14 @@ def id_set_fixture(self, request): style = BaseStyle(element(style_cxml)) expected_xml = xml(expected_style_cxml) return style, new_value, expected_xml + + @pytest.fixture(params=[ + ('w:style', WD_STYLE_TYPE.PARAGRAPH), + ('w:style{w:type=paragraph}', WD_STYLE_TYPE.PARAGRAPH), + ('w:style{w:type=character}', WD_STYLE_TYPE.CHARACTER), + ('w:style{w:type=numbering}', WD_STYLE_TYPE.LIST), + ]) + def type_get_fixture(self, request): + style_cxml, expected_value = request.param + style = BaseStyle(element(style_cxml)) + return style, expected_value From 21cd98ccb119ba363e56fce11d55c1f9315ceacc Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 02:37:56 -0800 Subject: [PATCH 287/809] acpt: add scenarios for _Style.name --- features/steps/styles.py | 24 ++++++++++++++++-------- features/sty-style-props.feature | 13 +++++++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/features/steps/styles.py b/features/steps/styles.py index adb18ea7e..4730777ff 100644 --- a/features/steps/styles.py +++ b/features/steps/styles.py @@ -28,21 +28,19 @@ def given_a_document_having_no_styles_part(context): context.document = Document(docx_path) -@given('a style having a known style id') -def given_a_style_having_a_known_style_id(context): +@given('a style having a known {attr_name}') +def given_a_style_having_a_known_attr_name(context, attr_name): docx_path = test_docx('sty-having-styles-part') document = Document(docx_path) context.style = document.styles['Normal'] -@given('a style having a known type') -def given_a_style_having_a_known_type(context): - docx_path = test_docx('sty-having-styles-part') - document = Document(docx_path) - context.style = document.styles['Normal'] +# when ===================================================== +@when('I assign a new name to the style') +def when_I_assign_a_new_name_to_the_style(context): + context.style.name = 'Foobar' -# when ===================================================== @when('I assign a new value to style.style_id') def when_I_assign_a_new_value_to_style_style_id(context): @@ -84,6 +82,16 @@ def then_len_styles_is_style_count(context, style_count_str): assert len(context.document.styles) == int(style_count_str) +@then('style.name is the {which} name') +def then_style_name_is_the_which_name(context, which): + expected_name = { + 'known': 'Normal', + 'new': 'Foobar', + }[which] + style = context.style + assert style.name == expected_name + + @then('style.style_id is the {which} style id') def then_style_style_id_is_the_which_style_id(context, which): expected_style_id = { diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index 84952efef..2d7da456c 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -4,6 +4,19 @@ Feature: Get and set style properties I need a set of read/write style properties + @wip + Scenario: Get name + Given a style having a known name + Then style.name is the known name + + + @wip + Scenario: Set name + Given a style having a known name + When I assign a new name to the style + Then style.name is the new name + + Scenario: Get style id Given a style having a known style id Then style.style_id is the known style id From 50af0f6d8f2ca90af27ac37f903af506407798ad Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 03:02:40 -0800 Subject: [PATCH 288/809] style: add _Style.name getter --- docx/oxml/__init__.py | 1 + docx/oxml/parts/styles.py | 11 +++++++++++ docx/styles/style.py | 7 +++++++ features/sty-style-props.feature | 1 - tests/styles/test_style.py | 13 +++++++++++++ 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 8b2d4a76e..284557969 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -87,6 +87,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:startOverride', CT_DecimalNumber) from .parts.styles import CT_Style, CT_Styles +register_element_cls('w:name', CT_String) register_element_cls('w:style', CT_Style) register_element_cls('w:styles', CT_Styles) diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index 3c6fee6ed..8c483fa5f 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -22,11 +22,22 @@ class CT_Style(BaseOxmlElement): 'w:personalCompose', 'w:personalReply', 'w:rsid', 'w:pPr', 'w:rPr', 'w:tblPr', 'w:trPr', 'w:tcPr', 'w:tblStylePr' ) + name = ZeroOrOne('w:name', successors=_tag_seq[1:]) pPr = ZeroOrOne('w:pPr', successors=_tag_seq[17:]) type = OptionalAttribute('w:type', WD_STYLE_TYPE) styleId = OptionalAttribute('w:styleId', ST_String) del _tag_seq + @property + def name_val(self): + """ + Value of ```` child or |None| if not present. + """ + name = self.name + if name is None: + return None + return name.val + class CT_Styles(BaseOxmlElement): """ diff --git a/docx/styles/style.py b/docx/styles/style.py index bb857e05d..ba046b6da 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -35,6 +35,13 @@ class BaseStyle(ElementProxy): __slots__ = () + @property + def name(self): + """ + The UI name of this style. + """ + return self._element.name_val + @property def style_id(self): """ diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index 2d7da456c..c8bf6a392 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -4,7 +4,6 @@ Feature: Get and set style properties I need a set of read/write style properties - @wip Scenario: Get name Given a style having a known name Then style.name is the known name diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py index 3e9ef350d..082b51004 100644 --- a/tests/styles/test_style.py +++ b/tests/styles/test_style.py @@ -108,6 +108,10 @@ def it_knows_its_type(self, type_get_fixture): style, expected_value = type_get_fixture assert style.type == expected_value + def it_knows_its_name(self, name_get_fixture): + style, expected_value = name_get_fixture + assert style.name == expected_value + # fixture -------------------------------------------------------- @pytest.fixture(params=[ @@ -131,6 +135,15 @@ def id_set_fixture(self, request): expected_xml = xml(expected_style_cxml) return style, new_value, expected_xml + @pytest.fixture(params=[ + ('w:style{w:type=table}', None), + ('w:style{w:type=table}/w:name{w:val=Boofar}', 'Boofar'), + ]) + def name_get_fixture(self, request): + style_cxml, expected_value = request.param + style = BaseStyle(element(style_cxml)) + return style, expected_value + @pytest.fixture(params=[ ('w:style', WD_STYLE_TYPE.PARAGRAPH), ('w:style{w:type=paragraph}', WD_STYLE_TYPE.PARAGRAPH), From f945031c4de9440fabe417d1cfe4a563437d9f4e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 20 Dec 2014 03:17:08 -0800 Subject: [PATCH 289/809] style: add _Style.name setter --- docx/oxml/parts/styles.py | 7 +++++++ docx/styles/style.py | 4 ++++ features/sty-style-props.feature | 1 - tests/styles/test_style.py | 16 ++++++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index 8c483fa5f..9da7e15b0 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -38,6 +38,13 @@ def name_val(self): return None return name.val + @name_val.setter + def name_val(self, value): + self._remove_name() + if value is not None: + name = self._add_name() + name.val = value + class CT_Styles(BaseOxmlElement): """ diff --git a/docx/styles/style.py b/docx/styles/style.py index ba046b6da..96ac14267 100644 --- a/docx/styles/style.py +++ b/docx/styles/style.py @@ -42,6 +42,10 @@ def name(self): """ return self._element.name_val + @name.setter + def name(self, value): + self._element.name_val = value + @property def style_id(self): """ diff --git a/features/sty-style-props.feature b/features/sty-style-props.feature index c8bf6a392..29b8b6a70 100644 --- a/features/sty-style-props.feature +++ b/features/sty-style-props.feature @@ -9,7 +9,6 @@ Feature: Get and set style properties Then style.name is the known name - @wip Scenario: Set name Given a style having a known name When I assign a new name to the style diff --git a/tests/styles/test_style.py b/tests/styles/test_style.py index 082b51004..a6c9ae7ce 100644 --- a/tests/styles/test_style.py +++ b/tests/styles/test_style.py @@ -112,6 +112,11 @@ def it_knows_its_name(self, name_get_fixture): style, expected_value = name_get_fixture assert style.name == expected_value + def it_can_change_its_name(self, name_set_fixture): + style, new_value, expected_xml = name_set_fixture + style.name = new_value + assert style._element.xml == expected_xml + # fixture -------------------------------------------------------- @pytest.fixture(params=[ @@ -144,6 +149,17 @@ def name_get_fixture(self, request): style = BaseStyle(element(style_cxml)) return style, expected_value + @pytest.fixture(params=[ + ('w:style', 'Foo', 'w:style/w:name{w:val=Foo}'), + ('w:style/w:name{w:val=Foo}', 'Bar', 'w:style/w:name{w:val=Bar}'), + ('w:style/w:name{w:val=Bar}', None, 'w:style'), + ]) + def name_set_fixture(self, request): + style_cxml, new_value, expected_style_cxml = request.param + style = BaseStyle(element(style_cxml)) + expected_xml = xml(expected_style_cxml) + return style, new_value, expected_xml + @pytest.fixture(params=[ ('w:style', WD_STYLE_TYPE.PARAGRAPH), ('w:style{w:type=paragraph}', WD_STYLE_TYPE.PARAGRAPH), From d0968211f12b39eb255707868ea2f50a158c3c07 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 24 Dec 2014 17:32:18 -0800 Subject: [PATCH 290/809] acpt: elaborate Document.add_paragraph() scenario Add case for specifying style as style object. --- features/api-add-paragraph.feature | 13 +++++++++++-- features/steps/api.py | 21 ++++++++++++--------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/features/api-add-paragraph.feature b/features/api-add-paragraph.feature index 5ca5e8367..f99542f0b 100644 --- a/features/api-add-paragraph.feature +++ b/features/api-add-paragraph.feature @@ -3,17 +3,26 @@ Feature: Add a paragraph with optional text and style As a programmer using the basic python-docx API I want to add a styled paragraph of text in a single step + Scenario: Add an empty paragraph Given a document When I add a paragraph without specifying text or style Then the last paragraph is the empty paragraph I added + Scenario: Add a paragraph specifying its text Given a document When I add a paragraph specifying its text Then the last paragraph contains the text I specified - Scenario: Add a paragraph specifying its style + + @wip + Scenario Outline: Add a paragraph specifying its style Given a document - When I add a paragraph specifying its style + When I add a paragraph specifying its style as a Then the last paragraph has the style I specified + + Examples: ways of specifying a style + | style-spec | + | style object | + | style name | diff --git a/features/steps/api.py b/features/steps/api.py index 96363b0a4..3f370d0fa 100644 --- a/features/steps/api.py +++ b/features/steps/api.py @@ -46,11 +46,15 @@ def when_add_page_break_to_document(context): document.add_page_break() -@when('I add a paragraph specifying its style') -def when_add_paragraph_specifying_style(context): +@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 - context.paragraph_style = 'barfoo' - document.add_paragraph(style=context.paragraph_style) + 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') @@ -135,11 +139,10 @@ def then_last_p_contains_specified_text(context): @then('the last paragraph has the style I specified') -def then_last_p_has_specified_style(context): - document = context.document - style = context.paragraph_style - p = document.paragraphs[-1] - assert p.style == style +def then_the_last_paragraph_has_the_style_I_specified(context): + document, expected_style = context.document, context.style + paragraph = document.paragraphs[-1] + assert paragraph.style == expected_style @then('the last paragraph is the empty paragraph I added') From 884e4f17916b7c6e3ca6469f656da83956b319df Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 24 Dec 2014 18:01:20 -0800 Subject: [PATCH 291/809] style: add _Styles._translate_special_case_names() * elaborate unit test to exercise new helper method * elaborate cxml grammar to allow spaces in attribute values --- docx/styles/styles.py | 22 ++++++++++++++++++++++ tests/styles/test_styles.py | 10 +++++----- tests/unitutil/cxml.py | 2 +- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/docx/styles/styles.py b/docx/styles/styles.py index 1f5805756..c7b45c567 100644 --- a/docx/styles/styles.py +++ b/docx/styles/styles.py @@ -25,6 +25,7 @@ def __getitem__(self, key): """ Enables dictionary-style access by style id or UI name. """ + key = self._translate_special_case_names(key) for get in (self._element.get_by_id, self._element.get_by_name): style_elm = get(key) if style_elm is not None: @@ -36,3 +37,24 @@ def __iter__(self): def __len__(self): return len(self._element.style_lst) + + @staticmethod + def _translate_special_case_names(name): + """ + Translate special-case style names from their English UI + counterparts. Some style names are stored differently than they + appear in the UI, with a leading lowercase letter, perhaps for legacy + reasons. + """ + return { + 'Caption': 'caption', + 'Heading 1': 'heading 1', + 'Heading 2': 'heading 2', + 'Heading 3': 'heading 3', + 'Heading 4': 'heading 4', + 'Heading 5': 'heading 5', + 'Heading 6': 'heading 6', + 'Heading 7': 'heading 7', + 'Heading 8': 'heading 8', + 'Heading 9': 'heading 9', + }.get(name, name) diff --git a/tests/styles/test_styles.py b/tests/styles/test_styles.py index 2d8c8f977..27c5adef5 100644 --- a/tests/styles/test_styles.py +++ b/tests/styles/test_styles.py @@ -64,16 +64,16 @@ def get_by_id_fixture(self, request): return styles, 'Foobar', expected_element @pytest.fixture(params=[ - ('w:styles/(w:style%s/w:name{w:val=foo},w:style,w:style)', 0), - ('w:styles/(w:style,w:style%s/w:name{w:val=foo},w:style)', 1), - ('w:styles/(w:style,w:style,w:style%s/w:name{w:val=foo})', 2), + ('w:styles/(w:style%s/w:name{w:val=foo},w:style)', 'foo', 0), + ('w:styles/(w:style,w:style%s/w:name{w:val=foo})', 'foo', 1), + ('w:styles/w:style%s/w:name{w:val=heading 1}', 'Heading 1', 0), ]) def get_by_name_fixture(self, request): - styles_cxml_tmpl, style_idx = request.param + styles_cxml_tmpl, key, style_idx = request.param styles_cxml = styles_cxml_tmpl % '{w:type=character}' styles = Styles(element(styles_cxml)) expected_element = styles._element[style_idx] - return styles, 'foo', expected_element + return styles, key, expected_element @pytest.fixture(params=[ ('w:styles/(w:style,w:style/w:name{w:val=foo},w:style)'), diff --git a/tests/unitutil/cxml.py b/tests/unitutil/cxml.py index c66bc0091..6bf0ce3f3 100644 --- a/tests/unitutil/cxml.py +++ b/tests/unitutil/cxml.py @@ -228,7 +228,7 @@ def grammar(): # np:attr_name=attr_val ---------------------- attr_name = Word(alphas + ':') - attr_val = Word(alphanums + '-.%') + attr_val = Word(alphanums + ' -.%') attr_def = Group(attr_name + equal + attr_val) attr_list = open_brace + delimitedList(attr_def) + close_brace From 06ead3c947ae74f81f582844a97e18245360aa07 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 24 Dec 2014 20:54:23 -0800 Subject: [PATCH 292/809] acpt: update scenario for Paragraph.style --- features/par-set-style.feature | 10 ---- features/par-style-prop.feature | 29 +++++++++++ features/steps/paragraph.py | 45 ++++++++++++++---- .../steps/test_files/par-known-styles.docx | Bin 0 -> 20901 bytes 4 files changed, 66 insertions(+), 18 deletions(-) delete mode 100644 features/par-set-style.feature create mode 100644 features/par-style-prop.feature create mode 100644 features/steps/test_files/par-known-styles.docx diff --git a/features/par-set-style.feature b/features/par-set-style.feature deleted file mode 100644 index 9b9f0e90c..000000000 --- a/features/par-set-style.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: Each paragraph has a read/write style - In order to use the stylesheet capability built into Word - As a developer using python-docx - I need the ability to get and set the style of a paragraph - - Scenario: Set the style of a paragraph - Given a paragraph - When I set the paragraph style - And I save the document - Then the paragraph has the style I set diff --git a/features/par-style-prop.feature b/features/par-style-prop.feature new file mode 100644 index 000000000..919bd6989 --- /dev/null +++ b/features/par-style-prop.feature @@ -0,0 +1,29 @@ +Feature: Each paragraph has a read/write style + In order to use the stylesheet capability built into Word + As a developer using python-docx + I need the ability to get and set the style of a paragraph + + + @wip + Scenario Outline: Get the style of a paragraph + Given a paragraph having

IqIxu|bS)%QqI_%d#|#O>Dn)*g1`Q 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 literal 0 HcmV?d00001 From c6ec277722f0a45a6416e5e13afede8ff3eeeb57 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 20 Jun 2014 01:49:13 -0700 Subject: [PATCH 090/809] sect: add Section.start_type getter Added InvalidXmlError to docx.exceptions, caused a circular import error when trying to get it into enum from xmlchemy. --- docs/dev/analysis/features/sections.rst | 1 + docx/enum/base.py | 6 ++++ docx/exceptions.py | 11 ++++++- docx/oxml/__init__.py | 4 +++ docx/oxml/section.py | 34 +++++++++++++++++++++ docx/section.py | 9 ++++++ features/sct-section-props.feature | 1 - tests/oxml/unitdata/section.py | 27 +++++++++++++++++ tests/test_section.py | 40 +++++++++++++++++++++++++ 9 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 docx/oxml/section.py create mode 100644 tests/oxml/unitdata/section.py create mode 100644 tests/test_section.py diff --git a/docs/dev/analysis/features/sections.rst b/docs/dev/analysis/features/sections.rst index ba3ba0a75..a76adb399 100644 --- a/docs/dev/analysis/features/sections.rst +++ b/docs/dev/analysis/features/sections.rst @@ -130,6 +130,7 @@ Inserting a section break (next page) produces this XML:: + diff --git a/docx/enum/base.py b/docx/enum/base.py index 471402850..e7c0882d2 100644 --- a/docx/enum/base.py +++ b/docx/enum/base.py @@ -9,6 +9,8 @@ import sys import textwrap +from ..exceptions import InvalidXmlError + def alias(*aliases): """ @@ -184,6 +186,10 @@ def from_xml(cls, xml_val): Return the enumeration member corresponding to the XML value *xml_val*. """ + if xml_val not in cls._xml_to_member: + raise InvalidXmlError( + "attribute value '%s' not valid for this type" % xml_val + ) return cls._xml_to_member[xml_val] @classmethod diff --git a/docx/exceptions.py b/docx/exceptions.py index 0263e10a4..00215615b 100644 --- a/docx/exceptions.py +++ b/docx/exceptions.py @@ -8,4 +8,13 @@ class PythonDocxError(Exception): - """Generic error class.""" + """ + Generic error class. + """ + + +class InvalidXmlError(PythonDocxError): + """ + Raised when invalid XML is encountered, such as on attempt to access a + missing required child element + """ diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index ea6275660..f9d43daa9 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -107,6 +107,10 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:style', CT_Style) register_element_cls('w:styles', CT_Styles) +from docx.oxml.section import CT_SectPr, CT_SectType +register_element_cls('w:sectPr', CT_SectPr) +register_element_cls('w:type', CT_SectType) + from docx.oxml.table import ( CT_Row, CT_Tbl, CT_TblGrid, CT_TblGridCol, CT_TblPr, CT_Tc ) diff --git a/docx/oxml/section.py b/docx/oxml/section.py new file mode 100644 index 000000000..3e293baef --- /dev/null +++ b/docx/oxml/section.py @@ -0,0 +1,34 @@ +# encoding: utf-8 + +""" +Section-related custom element classes. +""" + +from ..enum.section import WD_SECTION_START +from .xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrOne + + +class CT_SectPr(BaseOxmlElement): + """ + ```` element, the container element for section properties. + """ + type = ZeroOrOne('w:type') + + @property + def start_type(self): + """ + The member of the ``WD_SECTION_START`` enumeration corresponding to + the value of the ``val`` attribute of the ```` child element, + or ``WD_SECTION_START.NEW_PAGE`` if not present. + """ + type = self.type + if type is None or type.val is None: + return WD_SECTION_START.NEW_PAGE + return type.val + + +class CT_SectType(BaseOxmlElement): + """ + ```` element, defining the section start type. + """ + val = OptionalAttribute('w:val', WD_SECTION_START) diff --git a/docx/section.py b/docx/section.py index dd0991134..b6c8547e3 100644 --- a/docx/section.py +++ b/docx/section.py @@ -14,3 +14,12 @@ class Section(object): def __init__(self, sectPr): super(Section, self).__init__() self._sectPr = sectPr + + @property + def start_type(self): + """ + The member of the ``WD_SECTION`` enumeration corresponding to the + initial break behavior of this section, e.g. ``WD_SECTION.ODD_PAGE`` + if the section should begin on the next odd page. + """ + return self._sectPr.start_type diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index f4ed6406c..95b4d9f12 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -4,7 +4,6 @@ Feature: Access and change section properties I need a way to get and set the properties of a section - @wip Scenario Outline: Get section start type Given a section having start type Then the reported section start type is diff --git a/tests/oxml/unitdata/section.py b/tests/oxml/unitdata/section.py new file mode 100644 index 000000000..a7ba344b8 --- /dev/null +++ b/tests/oxml/unitdata/section.py @@ -0,0 +1,27 @@ +# encoding: utf-8 + +""" +Test data builders for section-related XML elements +""" + +from ...unitdata import BaseBuilder + + +class CT_SectPrBuilder(BaseBuilder): + __tag__ = 'w:sectPr' + __nspfxs__ = ('w',) + __attrs__ = () + + +class CT_SectTypeBuilder(BaseBuilder): + __tag__ = 'w:type' + __nspfxs__ = ('w',) + __attrs__ = ('w:val',) + + +def a_sectPr(): + return CT_SectPrBuilder() + + +def a_type(): + return CT_SectTypeBuilder() diff --git a/tests/test_section.py b/tests/test_section.py new file mode 100644 index 000000000..c0e4c9957 --- /dev/null +++ b/tests/test_section.py @@ -0,0 +1,40 @@ +# encoding: utf-8 + +""" +Test suite for the docx.section module +""" + +from __future__ import absolute_import, print_function, unicode_literals + +import pytest + +from docx.enum.section import WD_SECTION +from docx.section import Section + +from .oxml.unitdata.section import a_sectPr, a_type + + +class DescribeSection(object): + + def it_knows_its_start_type(self, start_type_fixture): + section, expected_start_type = start_type_fixture + assert section.start_type is expected_start_type + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + (None, WD_SECTION.NEW_PAGE), + ('continuous', WD_SECTION.CONTINUOUS), + ('nextPage', WD_SECTION.NEW_PAGE), + ('oddPage', WD_SECTION.ODD_PAGE), + ('evenPage', WD_SECTION.EVEN_PAGE), + ('nextColumn', WD_SECTION.NEW_COLUMN), + ]) + def start_type_fixture(self, request): + type_val, expected_start_type = request.param + sectPr_bldr = a_sectPr().with_nsdecls() + if type_val is not None: + sectPr_bldr.with_child(a_type().with_val(type_val)) + sectPr = sectPr_bldr.element + section = Section(sectPr) + return section, expected_start_type From 4c556a35eca06437f9526a6cb885d8017603e75a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 20 Jun 2014 15:40:44 -0700 Subject: [PATCH 091/809] acpt: add scenarios for Section.start_type setter --- features/sct-section-props.feature | 13 +++++++++++++ features/steps/section.py | 17 ++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index 95b4d9f12..cc88e477e 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -15,3 +15,16 @@ Feature: Access and change section properties | NEW_PAGE | | EVEN_PAGE | | ODD_PAGE | + + + @wip + Scenario Outline: Set section start type + Given a section having start type + When I set the section start type to + Then the reported section start type is + + Examples: Section start types + | initial-start-type | new-start-type | reported-start-type | + | CONTINUOUS | NEW_PAGE | NEW_PAGE | + | NEW_PAGE | ODD_PAGE | ODD_PAGE | + | NEW_COLUMN | None | NEW_PAGE | diff --git a/features/steps/section.py b/features/steps/section.py index 30e4aaa8f..3a1f41fe1 100644 --- a/features/steps/section.py +++ b/features/steps/section.py @@ -6,7 +6,7 @@ from __future__ import absolute_import, print_function, unicode_literals -from behave import given, then +from behave import given, then, when from docx import Document from docx.enum.section import WD_SECTION @@ -29,6 +29,21 @@ def given_a_section_having_start_type(context, start_type): context.section = document.sections[section_idx] +# when ===================================================== + +@when('I set the section start type to {start_type}') +def when_I_set_the_section_start_type_to_start_type(context, start_type): + new_start_type = { + 'None': None, + 'CONTINUOUS': WD_SECTION.CONTINUOUS, + 'EVEN_PAGE': WD_SECTION.EVEN_PAGE, + 'NEW_COLUMN': WD_SECTION.NEW_COLUMN, + 'NEW_PAGE': WD_SECTION.NEW_PAGE, + 'ODD_PAGE': WD_SECTION.ODD_PAGE, + }[start_type] + context.section.start_type = new_start_type + + # then ===================================================== @then('the reported section start type is {start_type}') From ab625af6eccc205a31620af85b2d9e0cdbd64e3c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 20 Jun 2014 16:21:28 -0700 Subject: [PATCH 092/809] sect: add Section.start_type.setter Add key check to XmlEnumeration.to_xml() that raises ValueError on value not a member of enumeration. --- docx/enum/base.py | 4 +++ docx/oxml/section.py | 8 ++++++ docx/section.py | 4 +++ features/sct-section-props.feature | 1 - tests/test_enum.py | 2 +- tests/test_section.py | 39 ++++++++++++++++++++++++------ 6 files changed, 49 insertions(+), 9 deletions(-) diff --git a/docx/enum/base.py b/docx/enum/base.py index e7c0882d2..f5218543e 100644 --- a/docx/enum/base.py +++ b/docx/enum/base.py @@ -197,6 +197,10 @@ def to_xml(cls, enum_val): """ Return the XML value of the enumeration value *enum_val*. """ + if enum_val not in cls._member_to_xml: + raise ValueError( + "value '%s' not in enumeration %s" % (enum_val, cls.__name__) + ) return cls._member_to_xml[enum_val] diff --git a/docx/oxml/section.py b/docx/oxml/section.py index 3e293baef..43dc42d18 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -26,6 +26,14 @@ def start_type(self): return WD_SECTION_START.NEW_PAGE return type.val + @start_type.setter + def start_type(self, value): + if value is None or value is WD_SECTION_START.NEW_PAGE: + self._remove_type() + return + type = self.get_or_add_type() + type.val = value + class CT_SectType(BaseOxmlElement): """ diff --git a/docx/section.py b/docx/section.py index b6c8547e3..1ee6f6ec4 100644 --- a/docx/section.py +++ b/docx/section.py @@ -23,3 +23,7 @@ def start_type(self): if the section should begin on the next odd page. """ return self._sectPr.start_type + + @start_type.setter + def start_type(self, value): + self._sectPr.start_type = value diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index cc88e477e..a96b3db5e 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -17,7 +17,6 @@ Feature: Access and change section properties | ODD_PAGE | - @wip Scenario Outline: Set section start type Given a section having start type When I set the section start type to diff --git a/tests/test_enum.py b/tests/test_enum.py index 06a9e80f3..5cf2371cc 100644 --- a/tests/test_enum.py +++ b/tests/test_enum.py @@ -90,7 +90,7 @@ class DescribeXmlEnumeration(object): def it_knows_the_XML_value_for_each_of_its_xml_members(self): assert XMLFOO.to_xml(XMLFOO.XML_RW) == 'attrVal' assert XMLFOO.to_xml(42) == 'attrVal' - with pytest.raises(KeyError): + with pytest.raises(ValueError): XMLFOO.to_xml(XMLFOO.RO) def it_can_map_each_of_its_xml_members_from_the_XML_value(self): diff --git a/tests/test_section.py b/tests/test_section.py index c0e4c9957..151afdc01 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -16,10 +16,15 @@ class DescribeSection(object): - def it_knows_its_start_type(self, start_type_fixture): - section, expected_start_type = start_type_fixture + def it_knows_its_start_type(self, start_type_get_fixture): + section, expected_start_type = start_type_get_fixture assert section.start_type is expected_start_type + def it_can_change_its_start_type(self, start_type_set_fixture): + section, new_start_type, expected_xml = start_type_set_fixture + section.start_type = new_start_type + assert section._sectPr.xml == expected_xml + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -30,11 +35,31 @@ def it_knows_its_start_type(self, start_type_fixture): ('evenPage', WD_SECTION.EVEN_PAGE), ('nextColumn', WD_SECTION.NEW_COLUMN), ]) - def start_type_fixture(self, request): + def start_type_get_fixture(self, request): type_val, expected_start_type = request.param - sectPr_bldr = a_sectPr().with_nsdecls() - if type_val is not None: - sectPr_bldr.with_child(a_type().with_val(type_val)) - sectPr = sectPr_bldr.element + sectPr = self.sectPr_bldr(type_val).element section = Section(sectPr) return section, expected_start_type + + @pytest.fixture(params=[ + ('oddPage', WD_SECTION.EVEN_PAGE, 'evenPage'), + ('nextPage', None, None), + ('continuous', WD_SECTION.NEW_PAGE, None), + (None, WD_SECTION.NEW_COLUMN, 'nextColumn'), + ]) + def start_type_set_fixture(self, request): + initial_type_val, new_type, expected_type_val = request.param + sectPr = self.sectPr_bldr(initial_type_val).element + section = Section(sectPr) + expected_xml = self.sectPr_bldr(expected_type_val).xml() + return section, new_type, expected_xml + + # fixture components --------------------------------------------- + + def sectPr_bldr(self, start_type=None): + sectPr_bldr = a_sectPr().with_nsdecls() + if start_type is not None: + sectPr_bldr.with_child( + a_type().with_val(start_type) + ) + return sectPr_bldr From 18f4b7d063cfc95bd343b55fa55f4101fed1a92d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 20 Jun 2014 20:49:23 -0700 Subject: [PATCH 093/809] acpt: add scenario for Section.page_width --- features/sct-section-props.feature | 7 +++++++ features/steps/section.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index a96b3db5e..e36443b93 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -27,3 +27,10 @@ Feature: Access and change section properties | CONTINUOUS | NEW_PAGE | NEW_PAGE | | NEW_PAGE | ODD_PAGE | ODD_PAGE | | NEW_COLUMN | None | NEW_PAGE | + + + @wip + Scenario: Get section page width + Given a section having known page dimension + Then the reported page width is 8.5 inches + And the reported page height is 11 inches diff --git a/features/steps/section.py b/features/steps/section.py index 3a1f41fe1..3145203b9 100644 --- a/features/steps/section.py +++ b/features/steps/section.py @@ -10,12 +10,19 @@ from docx import Document from docx.enum.section import WD_SECTION +from docx.shared import Inches from helpers import test_docx # given ==================================================== +@given('a section having known page dimension') +def given_a_section_having_known_page_dimension(context): + document = Document(test_docx('sct-section-props')) + context.section = document.sections[-1] + + @given('a section having start type {start_type}') def given_a_section_having_start_type(context, start_type): section_idx = { @@ -46,6 +53,16 @@ def when_I_set_the_section_start_type_to_start_type(context, start_type): # then ===================================================== +@then('the reported page width is 8.5 inches') +def then_the_reported_page_width_is_width(context): + assert context.section.page_width == Inches(8.5) + + +@then('the reported page height is 11 inches') +def then_the_reported_page_height_is_11_inches(context): + assert context.section.page_height == Inches(11) + + @then('the reported section start type is {start_type}') def then_the_reported_section_start_type_is_type(context, start_type): expected_start_type = { From 9b371a47296199c3d7b930ff38593b850b71161b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 20 Jun 2014 22:53:08 -0700 Subject: [PATCH 094/809] sect: add Section.page_width getter --- docx/oxml/__init__.py | 3 +- docx/oxml/section.py | 33 +++++++++++++- docx/section.py | 10 ++++ tests/oxml/unitdata/section.py | 10 ++++ tests/test_section.py | 83 +++++++++++++++++++++++++--------- 5 files changed, 116 insertions(+), 23 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index f9d43daa9..3ee320255 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -107,7 +107,8 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:style', CT_Style) register_element_cls('w:styles', CT_Styles) -from docx.oxml.section import CT_SectPr, CT_SectType +from docx.oxml.section import CT_PageSz, CT_SectPr, CT_SectType +register_element_cls('w:pgSz', CT_PageSz) register_element_cls('w:sectPr', CT_SectPr) register_element_cls('w:type', CT_SectType) diff --git a/docx/oxml/section.py b/docx/oxml/section.py index 43dc42d18..b10db5675 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -5,14 +5,45 @@ """ from ..enum.section import WD_SECTION_START +from .simpletypes import ST_TwipsMeasure from .xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrOne +class CT_PageSz(BaseOxmlElement): + """ + ```` element, defining page dimensions and orientation. + """ + w = OptionalAttribute('w:w', ST_TwipsMeasure) + + class CT_SectPr(BaseOxmlElement): """ ```` element, the container element for section properties. """ - type = ZeroOrOne('w:type') + __child_sequence__ = ( + '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', + ) + type = ZeroOrOne('w:type', successors=( + __child_sequence__[__child_sequence__.index('w:type')+1:] + )) + pgSz = ZeroOrOne('w:pgSz', successors=( + __child_sequence__[__child_sequence__.index('w:pgSz')+1:] + )) + + @property + def page_width(self): + """ + Value in EMU of the ``w`` attribute of the ```` child + element, or |None| if not present. + """ + pgSz = self.pgSz + if pgSz is None: + return None + return pgSz.w @property def start_type(self): diff --git a/docx/section.py b/docx/section.py index 1ee6f6ec4..9842f0764 100644 --- a/docx/section.py +++ b/docx/section.py @@ -15,6 +15,16 @@ def __init__(self, sectPr): super(Section, self).__init__() self._sectPr = sectPr + @property + def page_width(self): + """ + Total page width used for this section, inclusive of all edge spacing + values such as margins. Page orientation is taken into account, so + for example, its expected value would be ``Inches(11)`` for + letter-sized paper when orientation is landscape. + """ + return self._sectPr.page_width + @property def start_type(self): """ diff --git a/tests/oxml/unitdata/section.py b/tests/oxml/unitdata/section.py index a7ba344b8..23317ca9a 100644 --- a/tests/oxml/unitdata/section.py +++ b/tests/oxml/unitdata/section.py @@ -7,6 +7,12 @@ from ...unitdata import BaseBuilder +class CT_PageSzBuilder(BaseBuilder): + __tag__ = 'w:pgSz' + __nspfxs__ = ('w',) + __attrs__ = ('w:w', 'w:h', 'w:orient', 'w:code') + + class CT_SectPrBuilder(BaseBuilder): __tag__ = 'w:sectPr' __nspfxs__ = ('w',) @@ -19,6 +25,10 @@ class CT_SectTypeBuilder(BaseBuilder): __attrs__ = ('w:val',) +def a_pgSz(): + return CT_PageSzBuilder() + + def a_sectPr(): return CT_SectPrBuilder() diff --git a/tests/test_section.py b/tests/test_section.py index 151afdc01..d309e1373 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -10,8 +10,9 @@ from docx.enum.section import WD_SECTION from docx.section import Section +from docx.shared import Inches -from .oxml.unitdata.section import a_sectPr, a_type +from .oxml.unitdata.section import a_pgSz, a_sectPr, a_type class DescribeSection(object): @@ -25,41 +26,81 @@ def it_can_change_its_start_type(self, start_type_set_fixture): section.start_type = new_start_type assert section._sectPr.xml == expected_xml + def it_knows_its_page_width(self, page_width_get_fixture): + section, expected_page_width = page_width_get_fixture + assert section.page_width == expected_page_width + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ - (None, WD_SECTION.NEW_PAGE), - ('continuous', WD_SECTION.CONTINUOUS), - ('nextPage', WD_SECTION.NEW_PAGE), - ('oddPage', WD_SECTION.ODD_PAGE), - ('evenPage', WD_SECTION.EVEN_PAGE), - ('nextColumn', WD_SECTION.NEW_COLUMN), + (True, 1440, Inches(1)), + (True, None, None), + (False, None, None), + ]) + def page_width_get_fixture(self, request): + has_pgSz_child, w, expected_page_width = request.param + pgSz_bldr = self.pgSz_bldr(has_pgSz_child, w) + sectPr = self.sectPr_bldr(pgSz_bldr).element + section = Section(sectPr) + return section, expected_page_width + + @pytest.fixture(params=[ + (False, None, WD_SECTION.NEW_PAGE), + (True, None, WD_SECTION.NEW_PAGE), + (True, 'continuous', WD_SECTION.CONTINUOUS), + (True, 'nextPage', WD_SECTION.NEW_PAGE), + (True, 'oddPage', WD_SECTION.ODD_PAGE), + (True, 'evenPage', WD_SECTION.EVEN_PAGE), + (True, 'nextColumn', WD_SECTION.NEW_COLUMN), ]) def start_type_get_fixture(self, request): - type_val, expected_start_type = request.param - sectPr = self.sectPr_bldr(type_val).element + has_type_child, type_val, expected_start_type = request.param + type_bldr = self.type_bldr(has_type_child, type_val) + sectPr = self.sectPr_bldr(type_bldr).element section = Section(sectPr) return section, expected_start_type @pytest.fixture(params=[ - ('oddPage', WD_SECTION.EVEN_PAGE, 'evenPage'), - ('nextPage', None, None), - ('continuous', WD_SECTION.NEW_PAGE, None), - (None, WD_SECTION.NEW_COLUMN, 'nextColumn'), + (True, 'oddPage', WD_SECTION.EVEN_PAGE, True, 'evenPage'), + (True, 'nextPage', None, False, None), + (False, None, WD_SECTION.NEW_PAGE, False, None), + (True, 'continuous', WD_SECTION.NEW_PAGE, False, None), + (True, None, WD_SECTION.NEW_PAGE, False, None), + (True, None, WD_SECTION.NEW_COLUMN, True, 'nextColumn'), ]) def start_type_set_fixture(self, request): - initial_type_val, new_type, expected_type_val = request.param - sectPr = self.sectPr_bldr(initial_type_val).element + (has_type_child, initial_type_val, new_type, has_type_child_after, + expected_type_val) = request.param + # section ---------------------- + type_bldr = self.type_bldr(has_type_child, initial_type_val) + sectPr = self.sectPr_bldr(type_bldr).element section = Section(sectPr) - expected_xml = self.sectPr_bldr(expected_type_val).xml() + # expected_xml ----------------- + type_bldr = self.type_bldr(has_type_child_after, expected_type_val) + expected_xml = self.sectPr_bldr(type_bldr).xml() return section, new_type, expected_xml # fixture components --------------------------------------------- - def sectPr_bldr(self, start_type=None): + def pgSz_bldr(self, has_pgSz, w): + if not has_pgSz: + return None + pgSz_bldr = a_pgSz() + if w is not None: + pgSz_bldr.with_w(w) + return pgSz_bldr + + def sectPr_bldr(self, *child_bldrs): sectPr_bldr = a_sectPr().with_nsdecls() - if start_type is not None: - sectPr_bldr.with_child( - a_type().with_val(start_type) - ) + for child_bldr in child_bldrs: + if child_bldr is not None: + sectPr_bldr.with_child(child_bldr) return sectPr_bldr + + def type_bldr(self, has_type_elm, val): + if not has_type_elm: + return None + type_bldr = a_type() + if val is not None: + type_bldr.with_val(val) + return type_bldr From 6202ca6210cf50bf4a87d51ceefe94b269657004 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 20 Jun 2014 23:02:06 -0700 Subject: [PATCH 095/809] sect: add Section.page_width getter --- docx/oxml/section.py | 12 ++++++++++++ docx/section.py | 10 ++++++++++ features/sct-section-props.feature | 1 - tests/test_section.py | 22 ++++++++++++++++++++-- 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docx/oxml/section.py b/docx/oxml/section.py index b10db5675..3762e793f 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -14,6 +14,7 @@ class CT_PageSz(BaseOxmlElement): ```` element, defining page dimensions and orientation. """ w = OptionalAttribute('w:w', ST_TwipsMeasure) + h = OptionalAttribute('w:h', ST_TwipsMeasure) class CT_SectPr(BaseOxmlElement): @@ -34,6 +35,17 @@ class CT_SectPr(BaseOxmlElement): __child_sequence__[__child_sequence__.index('w:pgSz')+1:] )) + @property + def page_height(self): + """ + Value in EMU of the ``h`` attribute of the ```` child + element, or |None| if not present. + """ + pgSz = self.pgSz + if pgSz is None: + return None + return pgSz.h + @property def page_width(self): """ diff --git a/docx/section.py b/docx/section.py index 9842f0764..96ff13f5a 100644 --- a/docx/section.py +++ b/docx/section.py @@ -15,6 +15,16 @@ def __init__(self, sectPr): super(Section, self).__init__() self._sectPr = sectPr + @property + def page_height(self): + """ + Total page height used for this section, inclusive of all edge spacing + values such as margins. Page orientation is taken into account, so + for example, its expected value would be ``Inches(8.5)`` for + letter-sized paper when orientation is landscape. + """ + return self._sectPr.page_height + @property def page_width(self): """ diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index e36443b93..406fbbfbf 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -29,7 +29,6 @@ Feature: Access and change section properties | NEW_COLUMN | None | NEW_PAGE | - @wip Scenario: Get section page width Given a section having known page dimension Then the reported page width is 8.5 inches diff --git a/tests/test_section.py b/tests/test_section.py index d309e1373..4464591ac 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -30,8 +30,24 @@ def it_knows_its_page_width(self, page_width_get_fixture): section, expected_page_width = page_width_get_fixture assert section.page_width == expected_page_width + def it_knows_its_page_height(self, page_height_get_fixture): + section, expected_page_height = page_height_get_fixture + assert section.page_height == expected_page_height + # fixtures ------------------------------------------------------- + @pytest.fixture(params=[ + (True, 2880, Inches(2)), + (True, None, None), + (False, None, None), + ]) + def page_height_get_fixture(self, request): + has_pgSz_child, h, expected_page_height = request.param + pgSz_bldr = self.pgSz_bldr(has_pgSz_child, h=h) + sectPr = self.sectPr_bldr(pgSz_bldr).element + section = Section(sectPr) + return section, expected_page_height + @pytest.fixture(params=[ (True, 1440, Inches(1)), (True, None, None), @@ -39,7 +55,7 @@ def it_knows_its_page_width(self, page_width_get_fixture): ]) def page_width_get_fixture(self, request): has_pgSz_child, w, expected_page_width = request.param - pgSz_bldr = self.pgSz_bldr(has_pgSz_child, w) + pgSz_bldr = self.pgSz_bldr(has_pgSz_child, w=w) sectPr = self.sectPr_bldr(pgSz_bldr).element section = Section(sectPr) return section, expected_page_width @@ -82,12 +98,14 @@ def start_type_set_fixture(self, request): # fixture components --------------------------------------------- - def pgSz_bldr(self, has_pgSz, w): + def pgSz_bldr(self, has_pgSz, w=None, h=None): if not has_pgSz: return None pgSz_bldr = a_pgSz() if w is not None: pgSz_bldr.with_w(w) + if h is not None: + pgSz_bldr.with_h(h) return pgSz_bldr def sectPr_bldr(self, *child_bldrs): From 100f07b2850e86061a8c9ff0d125f81cff260c91 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 20 Jun 2014 23:17:09 -0700 Subject: [PATCH 096/809] acpt: add scenario for Section page size setters --- features/sct-section-props.feature | 11 ++++++++++- features/steps/section.py | 22 ++++++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index 406fbbfbf..94321ade5 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -29,7 +29,16 @@ Feature: Access and change section properties | NEW_COLUMN | None | NEW_PAGE | - Scenario: Get section page width + Scenario: Get section page size Given a section having known page dimension Then the reported page width is 8.5 inches And the reported page height is 11 inches + + + @wip + Scenario: Set section page size + Given a section having known page dimension + When I set the section page width to 11 inches + And I set the section page height to 8.5 inches + Then the reported page width is 11 inches + And the reported page height is 8.5 inches diff --git a/features/steps/section.py b/features/steps/section.py index 3145203b9..8354b58ac 100644 --- a/features/steps/section.py +++ b/features/steps/section.py @@ -38,6 +38,16 @@ def given_a_section_having_start_type(context, start_type): # when ===================================================== +@when('I set the section page height to {y} inches') +def when_I_set_the_section_page_height_to_y_inches(context, y): + context.section.page_height = Inches(float(y)) + + +@when('I set the section page width to {x} inches') +def when_I_set_the_section_page_width_to_x_inches(context, x): + context.section.page_width = Inches(float(x)) + + @when('I set the section start type to {start_type}') def when_I_set_the_section_start_type_to_start_type(context, start_type): new_start_type = { @@ -53,14 +63,14 @@ def when_I_set_the_section_start_type_to_start_type(context, start_type): # then ===================================================== -@then('the reported page width is 8.5 inches') -def then_the_reported_page_width_is_width(context): - assert context.section.page_width == Inches(8.5) +@then('the reported page width is {x} inches') +def then_the_reported_page_width_is_width(context, x): + assert context.section.page_width == Inches(float(x)) -@then('the reported page height is 11 inches') -def then_the_reported_page_height_is_11_inches(context): - assert context.section.page_height == Inches(11) +@then('the reported page height is {y} inches') +def then_the_reported_page_height_is_11_inches(context, y): + assert context.section.page_height == Inches(float(y)) @then('the reported section start type is {start_type}') From d5073ee877a4c0ceddc8118b88dff7d3a912aebb Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 20 Jun 2014 23:31:17 -0700 Subject: [PATCH 097/809] sect: add Section.page_width setter --- docx/oxml/section.py | 5 +++++ docx/section.py | 4 ++++ tests/test_section.py | 21 ++++++++++++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docx/oxml/section.py b/docx/oxml/section.py index 3762e793f..dc3406893 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -57,6 +57,11 @@ def page_width(self): return None return pgSz.w + @page_width.setter + def page_width(self, value): + pgSz = self.get_or_add_pgSz() + pgSz.w = value + @property def start_type(self): """ diff --git a/docx/section.py b/docx/section.py index 96ff13f5a..249d909aa 100644 --- a/docx/section.py +++ b/docx/section.py @@ -35,6 +35,10 @@ def page_width(self): """ return self._sectPr.page_width + @page_width.setter + def page_width(self, value): + self._sectPr.page_width = value + @property def start_type(self): """ diff --git a/tests/test_section.py b/tests/test_section.py index 4464591ac..bee2364c3 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -30,6 +30,11 @@ def it_knows_its_page_width(self, page_width_get_fixture): section, expected_page_width = page_width_get_fixture assert section.page_width == expected_page_width + def it_can_change_its_page_width(self, page_width_set_fixture): + section, new_page_width, expected_xml = page_width_set_fixture + section.page_width = new_page_width + assert section._sectPr.xml == expected_xml + def it_knows_its_page_height(self, page_height_get_fixture): section, expected_page_height = page_height_get_fixture assert section.page_height == expected_page_height @@ -60,6 +65,20 @@ def page_width_get_fixture(self, request): section = Section(sectPr) return section, expected_page_width + @pytest.fixture(params=[ + (None, None), + (Inches(1), 1440), + ]) + def page_width_set_fixture(self, request): + new_page_width, expected_w_val = request.param + # section ---------------------- + sectPr = self.sectPr_bldr().element + section = Section(sectPr) + # expected_xml ----------------- + pgSz_bldr = self.pgSz_bldr(w=expected_w_val) + expected_xml = self.sectPr_bldr(pgSz_bldr).xml() + return section, new_page_width, expected_xml + @pytest.fixture(params=[ (False, None, WD_SECTION.NEW_PAGE), (True, None, WD_SECTION.NEW_PAGE), @@ -98,7 +117,7 @@ def start_type_set_fixture(self, request): # fixture components --------------------------------------------- - def pgSz_bldr(self, has_pgSz, w=None, h=None): + def pgSz_bldr(self, has_pgSz=True, w=None, h=None): if not has_pgSz: return None pgSz_bldr = a_pgSz() From 81d49bda732a3a8cb82e049e8f5c8462b86bca07 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 20 Jun 2014 23:36:30 -0700 Subject: [PATCH 098/809] sect: add Section.page_height setter --- docx/oxml/section.py | 5 +++++ docx/section.py | 4 ++++ features/sct-section-props.feature | 1 - tests/test_section.py | 20 +++++++++++++++++++- 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/docx/oxml/section.py b/docx/oxml/section.py index dc3406893..0ac5f3233 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -46,6 +46,11 @@ def page_height(self): return None return pgSz.h + @page_height.setter + def page_height(self, value): + pgSz = self.get_or_add_pgSz() + pgSz.h = value + @property def page_width(self): """ diff --git a/docx/section.py b/docx/section.py index 249d909aa..60f1b3961 100644 --- a/docx/section.py +++ b/docx/section.py @@ -25,6 +25,10 @@ def page_height(self): """ return self._sectPr.page_height + @page_height.setter + def page_height(self, value): + self._sectPr.page_height = value + @property def page_width(self): """ diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index 94321ade5..99130a9f2 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -35,7 +35,6 @@ Feature: Access and change section properties And the reported page height is 11 inches - @wip Scenario: Set section page size Given a section having known page dimension When I set the section page width to 11 inches diff --git a/tests/test_section.py b/tests/test_section.py index bee2364c3..66f3cf41a 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -33,12 +33,16 @@ def it_knows_its_page_width(self, page_width_get_fixture): def it_can_change_its_page_width(self, page_width_set_fixture): section, new_page_width, expected_xml = page_width_set_fixture section.page_width = new_page_width - assert section._sectPr.xml == expected_xml def it_knows_its_page_height(self, page_height_get_fixture): section, expected_page_height = page_height_get_fixture assert section.page_height == expected_page_height + def it_can_change_its_page_height(self, page_height_set_fixture): + section, new_page_height, expected_xml = page_height_set_fixture + section.page_height = new_page_height + assert section._sectPr.xml == expected_xml + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -53,6 +57,20 @@ def page_height_get_fixture(self, request): section = Section(sectPr) return section, expected_page_height + @pytest.fixture(params=[ + (None, None), + (Inches(2), 2880), + ]) + def page_height_set_fixture(self, request): + new_page_height, expected_h_val = request.param + # section ---------------------- + sectPr = self.sectPr_bldr().element + section = Section(sectPr) + # expected_xml ----------------- + pgSz_bldr = self.pgSz_bldr(h=expected_h_val) + expected_xml = self.sectPr_bldr(pgSz_bldr).xml() + return section, new_page_height, expected_xml + @pytest.fixture(params=[ (True, 1440, Inches(1)), (True, None, None), From 212a65575c8addd6e66fa53dc19a860934ea2cec Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 21 Jun 2014 00:03:22 -0700 Subject: [PATCH 099/809] acpt: add scenarios for Section.orientation getter Also, add WD_ORIENTATION enumeration. --- docx/enum/section.py | 20 +++++++++++++++++ features/sct-section-props.feature | 11 +++++++++ features/steps/section.py | 21 +++++++++++++++++- .../steps/test_files/sct-section-props.docx | Bin 27763 -> 28546 bytes 4 files changed, 51 insertions(+), 1 deletion(-) diff --git a/docx/enum/section.py b/docx/enum/section.py index d586c20c0..27c163982 100644 --- a/docx/enum/section.py +++ b/docx/enum/section.py @@ -9,6 +9,26 @@ from .base import alias, XmlEnumeration, XmlMappedEnumMember +@alias('WD_ORIENT') +class WD_ORIENTATION(XmlEnumeration): + """ + Specifies the page layout orientation. + """ + + __ms_name__ = 'WdOrientation' + + __url__ = 'http://msdn.microsoft.com/en-us/library/office/ff837902.aspx' + + __members__ = ( + XmlMappedEnumMember( + 'PORTRAIT', 0, 'portrait', 'Portrait orientation.' + ), + XmlMappedEnumMember( + 'LANDSCAPE', 1, 'landscape', 'Landscape orientation.' + ), + ) + + @alias('WD_SECTION') class WD_SECTION_START(XmlEnumeration): """ diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index 99130a9f2..d4d315f42 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -41,3 +41,14 @@ Feature: Access and change section properties And I set the section page height to 8.5 inches Then the reported page width is 11 inches And the reported page height is 8.5 inches + + + @wip + Scenario Outline: Get section orientation + Given a section known to have orientation + Then the reported page orientation is + + Examples: Section page orientations + | orientation | reported-orientation | + | landscape | WD_ORIENT.LANDSCAPE | + | portrait | WD_ORIENT.PORTRAIT | diff --git a/features/steps/section.py b/features/steps/section.py index 8354b58ac..2e22415d8 100644 --- a/features/steps/section.py +++ b/features/steps/section.py @@ -9,7 +9,7 @@ from behave import given, then, when from docx import Document -from docx.enum.section import WD_SECTION +from docx.enum.section import WD_ORIENT, WD_SECTION from docx.shared import Inches from helpers import test_docx @@ -36,6 +36,16 @@ def given_a_section_having_start_type(context, start_type): context.section = document.sections[section_idx] +@given('a section known to have {orientation} orientation') +def given_a_section_having_known_orientation(context, orientation): + section_idx = { + 'landscape': 0, + 'portrait': 1 + }[orientation] + document = Document(test_docx('sct-section-props')) + context.section = document.sections[section_idx] + + # when ===================================================== @when('I set the section page height to {y} inches') @@ -63,6 +73,15 @@ def when_I_set_the_section_start_type_to_start_type(context, start_type): # then ===================================================== +@then('the reported page orientation is {orientation}') +def then_the_reported_page_orientation_is_orientation(context, orientation): + expected_value = { + 'WD_ORIENT.LANDSCAPE': WD_ORIENT.LANDSCAPE, + 'WD_ORIENT.PORTRAIT': WD_ORIENT.PORTRAIT, + }[orientation] + assert context.section.orientation == expected_value + + @then('the reported page width is {x} inches') def then_the_reported_page_width_is_width(context, x): assert context.section.page_width == Inches(float(x)) diff --git a/features/steps/test_files/sct-section-props.docx b/features/steps/test_files/sct-section-props.docx index c29c10e8142869f605831cb09e370c327163e839..373ddbcad11ce550cf2a71305813d6ff267e3437 100644 GIT binary patch delta 5675 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 Oz*u|pliRrddG Date: Sat, 21 Jun 2014 00:23:43 -0700 Subject: [PATCH 100/809] sect: add Section.orientation getter --- docx/oxml/section.py | 17 ++++++++++++++++- docx/section.py | 8 ++++++++ features/sct-section-props.feature | 1 - tests/test_section.py | 23 +++++++++++++++++++++-- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/docx/oxml/section.py b/docx/oxml/section.py index 0ac5f3233..664264a91 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -4,7 +4,7 @@ Section-related custom element classes. """ -from ..enum.section import WD_SECTION_START +from ..enum.section import WD_ORIENTATION, WD_SECTION_START from .simpletypes import ST_TwipsMeasure from .xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrOne @@ -15,6 +15,9 @@ class CT_PageSz(BaseOxmlElement): """ w = OptionalAttribute('w:w', ST_TwipsMeasure) h = OptionalAttribute('w:h', ST_TwipsMeasure) + orient = OptionalAttribute( + 'w:orient', WD_ORIENTATION, default=WD_ORIENTATION.PORTRAIT + ) class CT_SectPr(BaseOxmlElement): @@ -35,6 +38,18 @@ class CT_SectPr(BaseOxmlElement): __child_sequence__[__child_sequence__.index('w:pgSz')+1:] )) + @property + def orientation(self): + """ + The member of the ``WD_ORIENTATION`` enumeration corresponding to the + value of the ``orient`` attribute of the ```` child element, + or ``WD_ORIENTATION.PORTRAIT`` if not present. + """ + pgSz = self.pgSz + if pgSz is None: + return WD_ORIENTATION.PORTRAIT + return pgSz.orient + @property def page_height(self): """ diff --git a/docx/section.py b/docx/section.py index 60f1b3961..1d5cea8e3 100644 --- a/docx/section.py +++ b/docx/section.py @@ -15,6 +15,14 @@ def __init__(self, sectPr): super(Section, self).__init__() self._sectPr = sectPr + @property + def orientation(self): + """ + Page orientation for this section, one of ``WD_ORIENT.PORTRAIT`` or + ``WD_ORIENT.LANDSCAPE``. + """ + return self._sectPr.orientation + @property def page_height(self): """ diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index d4d315f42..a8db2851c 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -43,7 +43,6 @@ Feature: Access and change section properties And the reported page height is 8.5 inches - @wip Scenario Outline: Get section orientation Given a section known to have orientation Then the reported page orientation is diff --git a/tests/test_section.py b/tests/test_section.py index 66f3cf41a..5b629a619 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -8,7 +8,7 @@ import pytest -from docx.enum.section import WD_SECTION +from docx.enum.section import WD_ORIENT, WD_SECTION from docx.section import Section from docx.shared import Inches @@ -43,8 +43,25 @@ def it_can_change_its_page_height(self, page_height_set_fixture): section.page_height = new_page_height assert section._sectPr.xml == expected_xml + def it_knows_its_page_orientation(self, orientation_get_fixture): + section, expected_orientation = orientation_get_fixture + assert section.orientation is expected_orientation + # fixtures ------------------------------------------------------- + @pytest.fixture(params=[ + (True, 'landscape', WD_ORIENT.LANDSCAPE), + (True, 'portrait', WD_ORIENT.PORTRAIT), + (True, None, WD_ORIENT.PORTRAIT), + (False, None, WD_ORIENT.PORTRAIT), + ]) + def orientation_get_fixture(self, request): + has_pgSz_child, orient, expected_orientation = request.param + pgSz_bldr = self.pgSz_bldr(has_pgSz_child, orient=orient) + sectPr = self.sectPr_bldr(pgSz_bldr).element + section = Section(sectPr) + return section, expected_orientation + @pytest.fixture(params=[ (True, 2880, Inches(2)), (True, None, None), @@ -135,7 +152,7 @@ def start_type_set_fixture(self, request): # fixture components --------------------------------------------- - def pgSz_bldr(self, has_pgSz=True, w=None, h=None): + def pgSz_bldr(self, has_pgSz=True, w=None, h=None, orient=None): if not has_pgSz: return None pgSz_bldr = a_pgSz() @@ -143,6 +160,8 @@ def pgSz_bldr(self, has_pgSz=True, w=None, h=None): pgSz_bldr.with_w(w) if h is not None: pgSz_bldr.with_h(h) + if orient is not None: + pgSz_bldr.with_orient(orient) return pgSz_bldr def sectPr_bldr(self, *child_bldrs): From f6036e0dba533ef73ab84eb6045dae1915c25dd3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 21 Jun 2014 00:33:15 -0700 Subject: [PATCH 101/809] acpt: add scenarios for Section.orientation setter --- features/sct-section-props.feature | 13 +++++++++++++ features/steps/section.py | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index a8db2851c..798a2ac61 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -51,3 +51,16 @@ Feature: Access and change section properties | orientation | reported-orientation | | landscape | WD_ORIENT.LANDSCAPE | | portrait | WD_ORIENT.PORTRAIT | + + + @wip + Scenario Outline: Set section orientation + Given a section known to have orientation + When I set the section orientation to + Then the reported page orientation is + + Examples: Section page orientations + | initial-orientation | new-orientation | reported-orientation | + | portrait | WD_ORIENT.LANDSCAPE | WD_ORIENT.LANDSCAPE | + | landscape | WD_ORIENT.PORTRAIT | WD_ORIENT.PORTRAIT | + | landscape | None | WD_ORIENT.PORTRAIT | diff --git a/features/steps/section.py b/features/steps/section.py index 2e22415d8..78732f6ce 100644 --- a/features/steps/section.py +++ b/features/steps/section.py @@ -48,6 +48,16 @@ def given_a_section_having_known_orientation(context, orientation): # when ===================================================== +@when('I set the section orientation to {orientation}') +def when_I_set_the_section_orientation(context, orientation): + new_orientation = { + 'WD_ORIENT.PORTRAIT': WD_ORIENT.PORTRAIT, + 'WD_ORIENT.LANDSCAPE': WD_ORIENT.LANDSCAPE, + 'None': None, + }[orientation] + context.section.orientation = new_orientation + + @when('I set the section page height to {y} inches') def when_I_set_the_section_page_height_to_y_inches(context, y): context.section.page_height = Inches(float(y)) From bb3733921599e214551385f1f825dfd184d88e26 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 21 Jun 2014 00:43:15 -0700 Subject: [PATCH 102/809] sect: add Section.orientation setter --- docx/oxml/section.py | 5 +++++ docx/section.py | 4 ++++ features/sct-section-props.feature | 1 - tests/test_section.py | 20 ++++++++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docx/oxml/section.py b/docx/oxml/section.py index 664264a91..c27a0fa9e 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -50,6 +50,11 @@ def orientation(self): return WD_ORIENTATION.PORTRAIT return pgSz.orient + @orientation.setter + def orientation(self, value): + pgSz = self.get_or_add_pgSz() + pgSz.orient = value + @property def page_height(self): """ diff --git a/docx/section.py b/docx/section.py index 1d5cea8e3..604f9c301 100644 --- a/docx/section.py +++ b/docx/section.py @@ -23,6 +23,10 @@ def orientation(self): """ return self._sectPr.orientation + @orientation.setter + def orientation(self, value): + self._sectPr.orientation = value + @property def page_height(self): """ diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index 798a2ac61..559db5ad3 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -53,7 +53,6 @@ Feature: Access and change section properties | portrait | WD_ORIENT.PORTRAIT | - @wip Scenario Outline: Set section orientation Given a section known to have orientation When I set the section orientation to diff --git a/tests/test_section.py b/tests/test_section.py index 5b629a619..556081740 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -47,6 +47,11 @@ def it_knows_its_page_orientation(self, orientation_get_fixture): section, expected_orientation = orientation_get_fixture assert section.orientation is expected_orientation + def it_can_change_its_orientation(self, orientation_set_fixture): + section, new_orientation, expected_xml = orientation_set_fixture + section.orientation = new_orientation + assert section._sectPr.xml == expected_xml + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -62,6 +67,21 @@ def orientation_get_fixture(self, request): section = Section(sectPr) return section, expected_orientation + @pytest.fixture(params=[ + (WD_ORIENT.LANDSCAPE, 'landscape'), + (WD_ORIENT.PORTRAIT, None), + (None, None), + ]) + def orientation_set_fixture(self, request): + new_orientation, expected_orient_val = request.param + # section ---------------------- + sectPr = self.sectPr_bldr().element + section = Section(sectPr) + # expected_xml ----------------- + pgSz_bldr = self.pgSz_bldr(orient=expected_orient_val) + expected_xml = self.sectPr_bldr(pgSz_bldr).xml() + return section, new_orientation, expected_xml + @pytest.fixture(params=[ (True, 2880, Inches(2)), (True, None, None), From 4e56ce08dc2e4a4c3a3665a99d5369e2cd9dc3fb Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 21 Jun 2014 01:19:18 -0700 Subject: [PATCH 103/809] acpt: add scenario for Section margin getters --- features/sct-section-props.feature | 12 ++++++++++ features/steps/section.py | 22 ++++++++++++++++++ .../steps/test_files/sct-section-props.docx | Bin 28546 -> 28168 bytes 3 files changed, 34 insertions(+) diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index 559db5ad3..8a80c02aa 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -63,3 +63,15 @@ Feature: Access and change section properties | portrait | WD_ORIENT.LANDSCAPE | WD_ORIENT.LANDSCAPE | | landscape | WD_ORIENT.PORTRAIT | WD_ORIENT.PORTRAIT | | landscape | None | WD_ORIENT.PORTRAIT | + + + @wip + Scenario: Get section page margins + Given a section having known page margins + Then the reported left margin is 1.0 inches + And the reported right margin is 1.25 inches + And the reported top margin is 1.5 inches + And the reported bottom margin is 1.75 inches + And the reported gutter margin is 0.25 inches + And the reported header margin is 0.5 inches + And the reported footer margin is 0.75 inches diff --git a/features/steps/section.py b/features/steps/section.py index 78732f6ce..3e407e077 100644 --- a/features/steps/section.py +++ b/features/steps/section.py @@ -23,6 +23,12 @@ def given_a_section_having_known_page_dimension(context): context.section = document.sections[-1] +@given('a section having known page margins') +def given_a_section_having_known_page_margins(context): + document = Document(test_docx('sct-section-props')) + context.section = document.sections[0] + + @given('a section having start type {start_type}') def given_a_section_having_start_type(context, start_type): section_idx = { @@ -83,6 +89,22 @@ def when_I_set_the_section_start_type_to_start_type(context, start_type): # then ===================================================== +@then('the reported {margin_side} margin is {inches} inches') +def then_the_reported_margin_is_inches(context, margin_side, inches): + prop_name = { + 'left': 'left_margin', + 'right': 'right_margin', + 'top': 'top_margin', + 'bottom': 'bottom_margin', + 'gutter': 'gutter', + 'header': 'header_distance', + 'footer': 'footer_distance', + }[margin_side] + expected_value = Inches(float(inches)) + actual_value = getattr(context.section, prop_name) + assert actual_value == expected_value + + @then('the reported page orientation is {orientation}') def then_the_reported_page_orientation_is_orientation(context, orientation): expected_value = { diff --git a/features/steps/test_files/sct-section-props.docx b/features/steps/test_files/sct-section-props.docx index 373ddbcad11ce550cf2a71305813d6ff267e3437..15798b96b09a6b39a3808a8ff766d4801a10e877 100644 GIT binary patch delta 4290 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 From 445d5d28df3852dbd6b7e6700d73c04588c576bc Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 21 Jun 2014 02:26:52 -0700 Subject: [PATCH 104/809] sect: add Section margin getters --- docx/oxml/__init__.py | 3 +- docx/oxml/section.py | 102 ++++++++++++++++++++++++++++- docx/oxml/simpletypes.py | 15 +++++ docx/section.py | 60 +++++++++++++++++ features/sct-section-props.feature | 1 - tests/oxml/unitdata/section.py | 13 ++++ tests/test_section.py | 63 +++++++++++++++++- 7 files changed, 253 insertions(+), 4 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 3ee320255..07a766477 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -107,7 +107,8 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:style', CT_Style) register_element_cls('w:styles', CT_Styles) -from docx.oxml.section import CT_PageSz, CT_SectPr, CT_SectType +from docx.oxml.section import CT_PageMar, CT_PageSz, CT_SectPr, CT_SectType +register_element_cls('w:pgMar', CT_PageMar) register_element_cls('w:pgSz', CT_PageSz) register_element_cls('w:sectPr', CT_SectPr) register_element_cls('w:type', CT_SectType) diff --git a/docx/oxml/section.py b/docx/oxml/section.py index c27a0fa9e..f674e7076 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -5,10 +5,23 @@ """ from ..enum.section import WD_ORIENTATION, WD_SECTION_START -from .simpletypes import ST_TwipsMeasure +from .simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure from .xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrOne +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) + + class CT_PageSz(BaseOxmlElement): """ ```` element, defining page dimensions and orientation. @@ -37,6 +50,81 @@ class CT_SectPr(BaseOxmlElement): pgSz = ZeroOrOne('w:pgSz', successors=( __child_sequence__[__child_sequence__.index('w:pgSz')+1:] )) + pgMar = ZeroOrOne('w:pgMar', successors=( + __child_sequence__[__child_sequence__.index('w:pgMar')+1:] + )) + + @property + def bottom_margin(self): + """ + The value of the ``w:bottom`` attribute in the ```` child + element, as a |Length| object, or |None| if either the element or the + attribute is not present. + """ + pgMar = self.pgMar + if pgMar is None: + return None + return pgMar.bottom + + @property + def footer(self): + """ + The value of the ``w:footer`` attribute in the ```` child + element, as a |Length| object, or |None| if either the element or the + attribute is not present. + """ + pgMar = self.pgMar + if pgMar is None: + return None + return pgMar.footer + + @property + def gutter(self): + """ + The value of the ``w:gutter`` attribute in the ```` child + element, as a |Length| object, or |None| if either the element or the + attribute is not present. + """ + pgMar = self.pgMar + if pgMar is None: + return None + return pgMar.gutter + + @property + def header(self): + """ + The value of the ``w:header`` attribute in the ```` child + element, as a |Length| object, or |None| if either the element or the + attribute is not present. + """ + pgMar = self.pgMar + if pgMar is None: + return None + return pgMar.header + + @property + def left_margin(self): + """ + The value of the ``w:left`` attribute in the ```` child + element, as a |Length| object, or |None| if either the element or the + attribute is not present. + """ + pgMar = self.pgMar + if pgMar is None: + return None + return pgMar.left + + @property + def right_margin(self): + """ + The value of the ``w:right`` attribute in the ```` child + element, as a |Length| object, or |None| if either the element or the + attribute is not present. + """ + pgMar = self.pgMar + if pgMar is None: + return None + return pgMar.right @property def orientation(self): @@ -107,6 +195,18 @@ def start_type(self, value): type = self.get_or_add_type() type.val = value + @property + def top_margin(self): + """ + The value of the ``w:top`` attribute in the ```` child + element, as a |Length| object, or |None| if either the element or the + attribute is not present. + """ + pgMar = self.pgMar + if pgMar is None: + return None + return pgMar.top + class CT_SectType(BaseOxmlElement): """ diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 5bb94b05b..9a621e399 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -234,6 +234,21 @@ class ST_RelationshipId(XsdString): pass +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: + return ST_UniversalMeasure.convert_from_xml(str_value) + return Twips(int(str_value)) + + @classmethod + def convert_to_xml(cls, value): + emu = Emu(value) + twips = emu.twips + return str(twips) + + class ST_String(XsdString): pass diff --git a/docx/section.py b/docx/section.py index 604f9c301..4002e3da8 100644 --- a/docx/section.py +++ b/docx/section.py @@ -15,6 +15,50 @@ def __init__(self, sectPr): super(Section, self).__init__() self._sectPr = sectPr + @property + def bottom_margin(self): + """ + |Length| object representing the bottom margin for all pages in this + section in English Metric Units. + """ + return self._sectPr.bottom_margin + + @property + def footer_distance(self): + """ + |Length| object representing the distance from the bottom edge of the + page to the bottom edge of the footer. |None| if no setting is present + in the XML. + """ + return self._sectPr.footer + + @property + def gutter(self): + """ + |Length| object representing the page gutter size in English Metric + Units for all pages in this section. The page gutter is extra spacing + added to the *inner* margin to ensure even margins after page + binding. + """ + return self._sectPr.gutter + + @property + def header_distance(self): + """ + |Length| object representing the distance from the top edge of the + page to the top edge of the header. |None| if no setting is present + in the XML. + """ + return self._sectPr.header + + @property + def left_margin(self): + """ + |Length| object representing the left margin for all pages in this + section in English Metric Units. + """ + return self._sectPr.left_margin + @property def orientation(self): """ @@ -55,6 +99,14 @@ def page_width(self): def page_width(self, value): self._sectPr.page_width = value + @property + def right_margin(self): + """ + |Length| object representing the right margin for all pages in this + section in English Metric Units. + """ + return self._sectPr.right_margin + @property def start_type(self): """ @@ -67,3 +119,11 @@ def start_type(self): @start_type.setter def start_type(self, value): self._sectPr.start_type = value + + @property + def top_margin(self): + """ + |Length| object representing the top margin for all pages in this + section in English Metric Units. + """ + return self._sectPr.top_margin diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index 8a80c02aa..7624cd945 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -65,7 +65,6 @@ Feature: Access and change section properties | landscape | None | WD_ORIENT.PORTRAIT | - @wip Scenario: Get section page margins Given a section having known page margins Then the reported left margin is 1.0 inches diff --git a/tests/oxml/unitdata/section.py b/tests/oxml/unitdata/section.py index 23317ca9a..2f5f41151 100644 --- a/tests/oxml/unitdata/section.py +++ b/tests/oxml/unitdata/section.py @@ -7,6 +7,15 @@ from ...unitdata import BaseBuilder +class CT_PageMarBuilder(BaseBuilder): + __tag__ = 'w:pgMar' + __nspfxs__ = ('w',) + __attrs__ = ( + 'w:top', 'w:right', 'w:bottom', 'w:left', 'w:header', 'w:footer', + 'w:gutter' + ) + + class CT_PageSzBuilder(BaseBuilder): __tag__ = 'w:pgSz' __nspfxs__ = ('w',) @@ -25,6 +34,10 @@ class CT_SectTypeBuilder(BaseBuilder): __attrs__ = ('w:val',) +def a_pgMar(): + return CT_PageMarBuilder() + + def a_pgSz(): return CT_PageSzBuilder() diff --git a/tests/test_section.py b/tests/test_section.py index 556081740..8482c7468 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -12,7 +12,7 @@ from docx.section import Section from docx.shared import Inches -from .oxml.unitdata.section import a_pgSz, a_sectPr, a_type +from .oxml.unitdata.section import a_pgMar, a_pgSz, a_sectPr, a_type class DescribeSection(object): @@ -52,8 +52,47 @@ def it_can_change_its_orientation(self, orientation_set_fixture): section.orientation = new_orientation assert section._sectPr.xml == expected_xml + def it_knows_its_page_margins(self, margins_get_fixture): + section, left, right, top, bottom, gutter, header, footer = ( + margins_get_fixture + ) + assert section.left_margin == left + assert section.right_margin == right + assert section.top_margin == top + assert section.bottom_margin == bottom + assert section.gutter == gutter + assert section.header_distance == header + assert section.footer_distance == footer + # fixtures ------------------------------------------------------- + @pytest.fixture(params=[ + (True, 720, 720, 720, 720, 720, 720, 720), + (True, None, 360, None, 360, None, 360, None), + (False, None, None, None, None, None, None, None), + ]) + def margins_get_fixture(self, request): + (has_pgMar_child, left, right, top, bottom, gutter, header, + footer) = request.param + pgMar_bldr = self.pgMar_bldr( + has_pgMar_child, left=left, right=right, top=top, bottom=bottom, + gutter=gutter, header=header, footer=footer + ) + sectPr = self.sectPr_bldr(pgMar_bldr).element + section = Section(sectPr) + expected_left = left * 635 if left else None + expected_right = right * 635 if right else None + expected_top = top * 635 if top else None + expected_bottom = bottom * 635 if bottom else None + expected_gutter = gutter * 635 if gutter else None + expected_header = header * 635 if header else None + expected_footer = footer * 635 if footer else None + return ( + section, expected_left, expected_right, expected_top, + expected_bottom, expected_gutter, expected_header, + expected_footer + ) + @pytest.fixture(params=[ (True, 'landscape', WD_ORIENT.LANDSCAPE), (True, 'portrait', WD_ORIENT.PORTRAIT), @@ -172,6 +211,28 @@ def start_type_set_fixture(self, request): # fixture components --------------------------------------------- + def pgMar_bldr( + self, has_pgMar=True, left=None, right=None, top=None, + bottom=None, header=None, footer=None, gutter=None): + if not has_pgMar: + return None + pgMar_bldr = a_pgMar() + if left is not None: + pgMar_bldr.with_left(left) + if right is not None: + pgMar_bldr.with_right(right) + if top is not None: + pgMar_bldr.with_top(top) + if bottom is not None: + pgMar_bldr.with_bottom(bottom) + if header is not None: + pgMar_bldr.with_header(header) + if footer is not None: + pgMar_bldr.with_footer(footer) + if gutter is not None: + pgMar_bldr.with_gutter(gutter) + return pgMar_bldr + def pgSz_bldr(self, has_pgSz=True, w=None, h=None, orient=None): if not has_pgSz: return None From 56cc5f7c477e87f0007743a847b172d23b654a45 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 21 Jun 2014 02:42:39 -0700 Subject: [PATCH 105/809] acpt: add scenarios for Section margin setters --- features/sct-section-props.feature | 17 +++++++++++++++++ features/steps/section.py | 15 +++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index 7624cd945..89602141e 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -74,3 +74,20 @@ Feature: Access and change section properties And the reported gutter margin is 0.25 inches And the reported header margin is 0.5 inches And the reported footer margin is 0.75 inches + + + @wip + Scenario Outline: Set section page margins + Given a section having known page margins + When I set the margin to inches + Then the reported margin is inches + + Examples: Section margin settings + | margin-type | length | + | left | 1.0 | + | right | 1.25 | + | top | 0.75 | + | bottom | 1.5 | + | header | 0.25 | + | footer | 0.5 | + | gutter | 0.25 | diff --git a/features/steps/section.py b/features/steps/section.py index 3e407e077..bf7d75cc9 100644 --- a/features/steps/section.py +++ b/features/steps/section.py @@ -54,6 +54,21 @@ def given_a_section_having_known_orientation(context, orientation): # when ===================================================== +@when('I set the {margin_side} margin to {inches} inches') +def when_I_set_the_margin_side_length(context, margin_side, inches): + prop_name = { + 'left': 'left_margin', + 'right': 'right_margin', + 'top': 'top_margin', + 'bottom': 'bottom_margin', + 'gutter': 'gutter', + 'header': 'header_distance', + 'footer': 'footer_distance', + }[margin_side] + new_value = Inches(float(inches)) + setattr(context.section, prop_name, new_value) + + @when('I set the section orientation to {orientation}') def when_I_set_the_section_orientation(context, orientation): new_orientation = { From 65a59c68b85165dc9a8d4bbd7d3878e2a98a19ce Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 21 Jun 2014 12:41:23 -0700 Subject: [PATCH 106/809] sect: add Section margin setters --- docx/oxml/section.py | 35 ++++++++++++ docx/section.py | 28 ++++++++++ features/sct-section-props.feature | 1 - tests/test_section.py | 87 ++++++++++++++++++++---------- 4 files changed, 121 insertions(+), 30 deletions(-) diff --git a/docx/oxml/section.py b/docx/oxml/section.py index f674e7076..a5aa9dc36 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -66,6 +66,11 @@ def bottom_margin(self): return None return pgMar.bottom + @bottom_margin.setter + def bottom_margin(self, value): + pgMar = self.get_or_add_pgMar() + pgMar.bottom = value + @property def footer(self): """ @@ -78,6 +83,11 @@ def footer(self): return None return pgMar.footer + @footer.setter + def footer(self, value): + pgMar = self.get_or_add_pgMar() + pgMar.footer = value + @property def gutter(self): """ @@ -90,6 +100,11 @@ def gutter(self): return None return pgMar.gutter + @gutter.setter + def gutter(self, value): + pgMar = self.get_or_add_pgMar() + pgMar.gutter = value + @property def header(self): """ @@ -102,6 +117,11 @@ def header(self): return None return pgMar.header + @header.setter + def header(self, value): + pgMar = self.get_or_add_pgMar() + pgMar.header = value + @property def left_margin(self): """ @@ -114,6 +134,11 @@ def left_margin(self): return None return pgMar.left + @left_margin.setter + def left_margin(self, value): + pgMar = self.get_or_add_pgMar() + pgMar.left = value + @property def right_margin(self): """ @@ -126,6 +151,11 @@ def right_margin(self): return None return pgMar.right + @right_margin.setter + def right_margin(self, value): + pgMar = self.get_or_add_pgMar() + pgMar.right = value + @property def orientation(self): """ @@ -207,6 +237,11 @@ def top_margin(self): return None return pgMar.top + @top_margin.setter + def top_margin(self, value): + pgMar = self.get_or_add_pgMar() + pgMar.top = value + class CT_SectType(BaseOxmlElement): """ diff --git a/docx/section.py b/docx/section.py index 4002e3da8..69f57788d 100644 --- a/docx/section.py +++ b/docx/section.py @@ -23,6 +23,10 @@ def bottom_margin(self): """ return self._sectPr.bottom_margin + @bottom_margin.setter + def bottom_margin(self, value): + self._sectPr.bottom_margin = value + @property def footer_distance(self): """ @@ -32,6 +36,10 @@ def footer_distance(self): """ return self._sectPr.footer + @footer_distance.setter + def footer_distance(self, value): + self._sectPr.footer = value + @property def gutter(self): """ @@ -42,6 +50,10 @@ def gutter(self): """ return self._sectPr.gutter + @gutter.setter + def gutter(self, value): + self._sectPr.gutter = value + @property def header_distance(self): """ @@ -51,6 +63,10 @@ def header_distance(self): """ return self._sectPr.header + @header_distance.setter + def header_distance(self, value): + self._sectPr.header = value + @property def left_margin(self): """ @@ -59,6 +75,10 @@ def left_margin(self): """ return self._sectPr.left_margin + @left_margin.setter + def left_margin(self, value): + self._sectPr.left_margin = value + @property def orientation(self): """ @@ -107,6 +127,10 @@ def right_margin(self): """ return self._sectPr.right_margin + @right_margin.setter + def right_margin(self, value): + self._sectPr.right_margin = value + @property def start_type(self): """ @@ -127,3 +151,7 @@ def top_margin(self): section in English Metric Units. """ return self._sectPr.top_margin + + @top_margin.setter + def top_margin(self, value): + self._sectPr.top_margin = value diff --git a/features/sct-section-props.feature b/features/sct-section-props.feature index 89602141e..412d93f02 100644 --- a/features/sct-section-props.feature +++ b/features/sct-section-props.feature @@ -76,7 +76,6 @@ Feature: Access and change section properties And the reported footer margin is 0.75 inches - @wip Scenario Outline: Set section page margins Given a section having known page margins When I set the margin to inches diff --git a/tests/test_section.py b/tests/test_section.py index 8482c7468..709b51c4a 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -64,6 +64,15 @@ def it_knows_its_page_margins(self, margins_get_fixture): assert section.header_distance == header assert section.footer_distance == footer + def it_can_change_its_page_margins(self, margins_set_fixture): + section, margin_prop_name, new_value, expected_xml = ( + margins_set_fixture + ) + print(section._sectPr.xml) + setattr(section, margin_prop_name, new_value) + print(section._sectPr.xml) + assert section._sectPr.xml == expected_xml + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -74,25 +83,50 @@ def it_knows_its_page_margins(self, margins_get_fixture): def margins_get_fixture(self, request): (has_pgMar_child, left, right, top, bottom, gutter, header, footer) = request.param - pgMar_bldr = self.pgMar_bldr( - has_pgMar_child, left=left, right=right, top=top, bottom=bottom, - gutter=gutter, header=header, footer=footer - ) + pgMar_bldr = self.pgMar_bldr(**{ + 'has_pgMar': has_pgMar_child, 'left': left, 'right': right, + 'top': top, 'bottom': bottom, 'gutter': gutter, 'header': header, + 'footer': footer + }) sectPr = self.sectPr_bldr(pgMar_bldr).element section = Section(sectPr) - expected_left = left * 635 if left else None - expected_right = right * 635 if right else None - expected_top = top * 635 if top else None - expected_bottom = bottom * 635 if bottom else None - expected_gutter = gutter * 635 if gutter else None - expected_header = header * 635 if header else None - expected_footer = footer * 635 if footer else None + expected_left = self.twips_to_emu(left) + expected_right = self.twips_to_emu(right) + expected_top = self.twips_to_emu(top) + expected_bottom = self.twips_to_emu(bottom) + expected_gutter = self.twips_to_emu(gutter) + expected_header = self.twips_to_emu(header) + expected_footer = self.twips_to_emu(footer) return ( section, expected_left, expected_right, expected_top, expected_bottom, expected_gutter, expected_header, expected_footer ) + @pytest.fixture(params=[ + ('left', 1440, 720), ('right', None, 1800), ('top', 2160, None), + ('bottom', 720, 2160), ('gutter', None, 360), ('header', 720, 630), + ('footer', None, 810) + ]) + def margins_set_fixture(self, request): + margin_side, initial_margin, new_margin = request.param + # section ---------------------- + pgMar_bldr = self.pgMar_bldr(**{margin_side: initial_margin}) + sectPr = self.sectPr_bldr(pgMar_bldr).element + section = Section(sectPr) + # property name ---------------- + property_name = { + 'left': 'left_margin', 'right': 'right_margin', + 'top': 'top_margin', 'bottom': 'bottom_margin', + 'gutter': 'gutter', 'header': 'header_distance', + 'footer': 'footer_distance' + }[margin_side] + # expected_xml ----------------- + pgMar_bldr = self.pgMar_bldr(**{margin_side: new_margin}) + expected_xml = self.sectPr_bldr(pgMar_bldr).xml() + new_value = self.twips_to_emu(new_margin) + return section, property_name, new_value, expected_xml + @pytest.fixture(params=[ (True, 'landscape', WD_ORIENT.LANDSCAPE), (True, 'portrait', WD_ORIENT.PORTRAIT), @@ -211,26 +245,21 @@ def start_type_set_fixture(self, request): # fixture components --------------------------------------------- - def pgMar_bldr( - self, has_pgMar=True, left=None, right=None, top=None, - bottom=None, header=None, footer=None, gutter=None): - if not has_pgMar: + @staticmethod + def twips_to_emu(twips): + if twips is None: + return None + return twips * 635 + + def pgMar_bldr(self, **kwargs): + if kwargs.pop('has_pgMar', True) is False: return None pgMar_bldr = a_pgMar() - if left is not None: - pgMar_bldr.with_left(left) - if right is not None: - pgMar_bldr.with_right(right) - if top is not None: - pgMar_bldr.with_top(top) - if bottom is not None: - pgMar_bldr.with_bottom(bottom) - if header is not None: - pgMar_bldr.with_header(header) - if footer is not None: - pgMar_bldr.with_footer(footer) - if gutter is not None: - pgMar_bldr.with_gutter(gutter) + for key, value in kwargs.items(): + if value is None: + continue + set_attr_method = getattr(pgMar_bldr, 'with_%s' % key) + set_attr_method(value) return pgMar_bldr def pgSz_bldr(self, has_pgSz=True, w=None, h=None, orient=None): From a7d99154db21157d4b444ad44f329098ca844503 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 21 Jun 2014 21:19:58 -0700 Subject: [PATCH 107/809] docs: document analysis for Document.add_section() --- docs/dev/analysis/features/sections.rst | 262 +++++++++++++++--------- 1 file changed, 163 insertions(+), 99 deletions(-) diff --git a/docs/dev/analysis/features/sections.rst b/docs/dev/analysis/features/sections.rst index a76adb399..6c1e37519 100644 --- a/docs/dev/analysis/features/sections.rst +++ b/docs/dev/analysis/features/sections.rst @@ -9,32 +9,49 @@ from line, page, and column breaks. The former adds a ```` element to the last paragraph in the new section. The latter inserts a ```` element in a run. +The last section in a document is specified by a ```` element +appearing as the last child of the ```` element. While this element +is optional, it appears that Word creates it for all files. Since most files +have only a single section, the most common case is where this is the only +```` element. -Implementation notes --------------------- +Additional sections are specified by a ``w:p/w:pPr/w:sectPr`` element in the +last paragraph of the section. Any content in that paragraph is part of the +section defined by its ```` element. The subsequent section begins +with the following paragraph. -Implementing adding a section break should probably wait until after the -ability to set at least a core subset of the section properties. First ones -are probably: +When a section break is inserted using the Word UI, the following steps +occur: -* page size -* margins +1. The next-occurring ```` element is copied and added to the + current paragraph. (It would be interesting to see what happens when that + paragraph already has a ```` element.) +2. A new paragraph is inserted after the current paragraph. The text occuring + after the cursor position is moved to the new paragraph. +3. The start-type (e.g. next page) of the next-occuring ```` + element is changed to reflect the type chosen by the user from the UI. -The other thing it will entail is locating the next ```` element in -document order and copying its child elements. -I'm thinking the sequence is: +Candidate protocol +------------------ -1. document.sections -2. section page setup properties -3. paragraph.make_section_break() (or whatever, something better perhaps) +The following interactive session demonstrates the proposed protocol for +inserting a section break, illustrating adding a landscape section after an +existing portrait section:: + >>> section_1 = document.sections[0] + >>> document.add_paragraph('This paragraph appears in section 1.') + >>> section_2 = document.add_section(WD_SECTION.EVEN_PAGE) + >>> section_2.orientation + PORTRAIT (0) + >>> section_2.orientation = WD_ORIENT.LANDSCAPE + >>> section_2.page_width = section_1.page_height + >>> section_2.page_height = section_1.page_width + >>> document.add_paragraph('This paragraph appears in section 2.') -Candidate protocol ------------------- The following interactive session demonstrates the proposed protocol for -working with sections:: +setting section properties:: >>> sections = document.sections >>> sections @@ -66,52 +83,155 @@ working with sections:: Word behavior ------------- -When inserting a section break in Word, there is no dialog box presented and no -parameters are supplied. Word simply copies the section details from the -current section (the next ```` element in the document) to provide -the starting point. Experimentation would be required to determine exactly what -items are copied, but it at least includes the page size, margins, and column -spacing. +* A paragraph containing a section break ( element) does not + produce a ¶ glyph in the Word UI. +* The section break indicator/double-line appears directly after the text of + the paragraph in which the appears. If the section break + paragraph has no text, the indicator line appears immediately after the + paragraph mark of the prior paragraph. + + +Before and after analysis +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. highlight:: xml + +Baseline document containing two paragraphs:: + + + + + Paragraph 1 + + + + + Paragraph 2 + + + + + + + + + + + +Odd-page section inserted before paragraph mark in Paragraph 1:: + + + + + + + + + + + + + Paragraph 1 + + + + + + Paragraph 2 + + + + + + + + + + + +UI shows empty ¶ mark in first position of new next page. Section break +indicator appears directly after Paragraph 1 text, with no intervening +¶ mark. + + +Even-page section break inserted before first character in Paragraph 2:: + + + + + Paragraph 1 + + + + + + + + + + + + + + + + + Paragraph 2 + + + + + + + + + + Enumerations ------------ -* `WdSectionStart Enumeration on MSDN`_ +WD_SECTION_START +~~~~~~~~~~~~~~~~ -.. _WdSectionStart Enumeration on MSDN: - http://msdn.microsoft.com/en-us/library/office/bb238171.aspx +alias: **WD_SECTION** -:: +`WdSectionStart Enumeration on MSDN`_ - @alias(WD_SECTION) - class WD_SECTION_START(Enumeration): +.. _WdSectionStart Enumeration on MSDN: + http://msdn.microsoft.com/en-us/library/office/bb238171.aspx CONTINUOUS (0) Continuous section break. -EVENPAGE (3) - Even pages section break. - -NEWCOLUMN (1) +NEW_COLUMN (1) New column section break. -NEWPAGE (2) +NEW_PAGE (2) New page section break. -ODDPAGE (4) +EVEN_PAGE (3) + Even pages section break. + +ODD_PAGE (4) Odd pages section break. -* `WdOrientation Enumeration on MSDN`_ +WD_ORIENTATION +~~~~~~~~~~~~~~ -.. _WdOrientation Enumeration on MSDN: - http://msdn.microsoft.com/en-us/library/office/ff837902.aspx +alias: **WD_ORIENT** -:: +`WdOrientation Enumeration on MSDN`_ - @alias(WD_ORIENT) - class WD_ORIENTATION(Enumeration): +.. _WdOrientation Enumeration on MSDN: + http://msdn.microsoft.com/en-us/library/office/ff837902.aspx LANDSCAPE (1) Landscape orientation. @@ -120,29 +240,6 @@ PORTRAIT (0) Portrait orientation. -Specimen XML ------------- - -.. highlight:: xml - -Inserting a section break (next page) produces this XML:: - - - - - - - - - - - - - Text before section break insertion point} - - - - Schema excerpt -------------- @@ -152,42 +249,9 @@ Schema excerpt - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + From 9523610b93797d23db49b13ef058ae56e2b11292 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 21 Jun 2014 21:25:31 -0700 Subject: [PATCH 108/809] acpt: add doc-add-section.feature --- features/doc-add-section.feature | 14 +++++ features/steps/document.py | 49 +++++++++++++++++- .../steps/test_files/doc-add-section.docx | Bin 0 -> 21259 bytes 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 features/doc-add-section.feature create mode 100644 features/steps/test_files/doc-add-section.docx diff --git a/features/doc-add-section.feature b/features/doc-add-section.feature new file mode 100644 index 000000000..274ab7b61 --- /dev/null +++ b/features/doc-add-section.feature @@ -0,0 +1,14 @@ +Feature: Add a document section + In order to change page layout mid-document + As a developer using python-docx + I need a way to add a new section to a document + + + @wip + Scenario: Add a landscape section to a portrait document + Given a single-section document having portrait layout + When I add an even-page section to the document + And I change the new section layout to landscape + Then the document has two sections + And the first section is portrait + And the second section is landscape diff --git a/features/steps/document.py b/features/steps/document.py index 33b873d41..b99b27d12 100644 --- a/features/steps/document.py +++ b/features/steps/document.py @@ -6,9 +6,10 @@ from __future__ import absolute_import, print_function, unicode_literals -from behave import given, then +from behave import given, then, when from docx import Document +from docx.enum.section import WD_ORIENT, WD_SECTION from docx.parts.document import Sections from docx.section import Section @@ -28,6 +29,29 @@ def given_a_section_collection(context): context.sections = document.sections +@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')) + section = context.document.sections[-1] + context.original_dimensions = (section.page_width, section.page_height) + + +# when ==================================================== + +@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') +def when_I_change_the_new_section_layout_to_landscape(context): + new_height, new_width = context.original_dimensions + section = context.section + section.orientation = WD_ORIENT.LANDSCAPE + section.page_width = new_width + section.page_height = new_height + + # then ==================================================== @then('I can access a section by index') @@ -55,9 +79,32 @@ def then_I_can_iterate_over_the_sections(context): assert actual_count == 3 +@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') +def then_the_first_section_is_portrait(context): + first_section = context.document.sections[0] + expected_width, expected_height = context.original_dimensions + assert first_section.orientation == WD_ORIENT.PORTRAIT + assert first_section.page_width == expected_width + assert first_section.page_height == expected_height + + @then('the length of the section collection is 3') def then_the_length_of_the_section_collection_is_3(context): sections = context.document.sections assert len(sections) == 3, ( 'expected len(sections) of 2, got %s' % len(sections) ) + + +@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 + assert new_section.orientation == WD_ORIENT.LANDSCAPE + assert new_section.page_width == expected_width + assert new_section.page_height == expected_height diff --git a/features/steps/test_files/doc-add-section.docx b/features/steps/test_files/doc-add-section.docx new file mode 100644 index 0000000000000000000000000000000000000000..4b17ce6ab8ccb0f531463b86c5c023edee51d859 GIT binary patch literal 21259 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 literal 0 HcmV?d00001 From 9351335236b46c78b959aa4cccc69e5735c349a6 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 22 Jun 2014 02:26:22 -0700 Subject: [PATCH 109/809] test: reorg fixtures in DescribeDocument --- tests/test_api.py | 76 ++++++++++++++++++++++++----------------------- 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 1adc1ebab..9df3f4e6b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -167,6 +167,10 @@ def it_creates_styles_part_on_first_access_if_not_present( # fixtures ------------------------------------------------------- + @pytest.fixture + def add_empty_paragraph_fixture(self, document, document_part_, p_): + return document, document_part_, p_ + @pytest.fixture(params=[0, 1, 2, 5, 9]) def add_heading_fixture(self, request, document, add_paragraph_, p_): level = request.param @@ -174,20 +178,10 @@ def add_heading_fixture(self, request, document, add_paragraph_, p_): style = 'Title' if level == 0 else 'Heading%d' % level return document, add_paragraph_, p_, text, level, style - @pytest.fixture - def add_empty_paragraph_fixture(self, document, document_part_, p_): - return document, document_part_, p_ - @pytest.fixture def add_page_break_fixture(self, document, document_part_, p_, r_): return document, document_part_, p_, r_ - @pytest.fixture - def add_paragraph_(self, request, p_): - return method_mock( - request, Document, 'add_paragraph', return_value=p_ - ) - @pytest.fixture(params=[ (None, None, 200, 100), (1000, 500, 1000, 500), @@ -225,6 +219,41 @@ def add_text_paragraph_fixture(self, document, p_, r_): text = 'foobar\rbarfoo' return document, text, p_, r_ + @pytest.fixture + def init_fixture(self, docx_, open_): + return docx_, open_ + + @pytest.fixture + def num_part_get_fixture(self, document, document_part_, numbering_part_): + document_part_.part_related_by.return_value = numbering_part_ + return document, document_part_, numbering_part_ + + @pytest.fixture + def open_fixture(self, docx_, Package_, package_, document_part_): + return docx_, Package_, package_, document_part_ + + @pytest.fixture + def paragraphs_fixture(self, document, paragraphs_): + return document, paragraphs_ + + @pytest.fixture + def save_fixture(self, request, open_, package_): + file_ = instance_mock(request, str) + document = Document() + return document, package_, file_ + + @pytest.fixture + def tables_fixture(self, document, tables_): + return document, tables_ + + # fixture components --------------------------------------------- + + @pytest.fixture + def add_paragraph_(self, request, p_): + return method_mock( + request, Document, 'add_paragraph', return_value=p_ + ) + @pytest.fixture def default_docx_(self, request): return var_mock(request, 'docx.api._default_docx_path') @@ -254,10 +283,6 @@ def document_part_(self, request, p_, paragraphs_, table_, tables_): def docx_(self, request): return instance_mock(request, str) - @pytest.fixture - def init_fixture(self, docx_, open_): - return docx_, open_ - @pytest.fixture def inline_shapes_(self, request): return instance_mock(request, InlineShapes) @@ -268,11 +293,6 @@ def num_part_create_fixture( document_part_.part_related_by.side_effect = KeyError return document, NumberingPart_, document_part_, numbering_part_ - @pytest.fixture - def num_part_get_fixture(self, document, document_part_, numbering_part_): - document_part_.part_related_by.return_value = numbering_part_ - return document, document_part_, numbering_part_ - @pytest.fixture def NumberingPart_(self, request, numbering_part_): NumberingPart_ = class_mock(request, 'docx.api.NumberingPart') @@ -290,10 +310,6 @@ def open_(self, request, document_part_, package_): return_value=(document_part_, package_) ) - @pytest.fixture - def open_fixture(self, docx_, Package_, package_, document_part_): - return docx_, Package_, package_, document_part_ - @pytest.fixture def p_(self, request, r_): p_ = instance_mock(request, Paragraph) @@ -316,20 +332,10 @@ def package_(self, request, document_part_): def paragraphs_(self, request): return instance_mock(request, list) - @pytest.fixture - def paragraphs_fixture(self, document, paragraphs_): - return document, paragraphs_ - @pytest.fixture def r_(self, request): return instance_mock(request, Run) - @pytest.fixture - def save_fixture(self, request, open_, package_): - file_ = instance_mock(request, str) - document = Document() - return document, package_, file_ - @pytest.fixture def StylesPart_(self, request, styles_part_): StylesPart_ = class_mock(request, 'docx.api.StylesPart') @@ -358,7 +364,3 @@ def table_(self, request): @pytest.fixture def tables_(self, request): return instance_mock(request, list) - - @pytest.fixture - def tables_fixture(self, document, tables_): - return document, tables_ From a665ad154558dd7097e4a38a64efa8c6278b4c9e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 21 Jun 2014 23:16:54 -0700 Subject: [PATCH 110/809] sect: add Document.add_section() Also, reorder fixtures a bit in test_api.py. --- docx/api.py | 8 ++++++++ docx/parts/document.py | 8 ++++++++ tests/test_api.py | 25 ++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docx/api.py b/docx/api.py index 6e9b72bac..a1c109fb4 100644 --- a/docx/api.py +++ b/docx/api.py @@ -10,6 +10,7 @@ import os +from docx.enum.section import WD_SECTION from docx.enum.text import WD_BREAK from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT from docx.package import Package @@ -100,6 +101,13 @@ def add_picture(self, image_path_or_stream, width=None, height=None): return picture + def add_section(self, start_type=WD_SECTION.NEW_PAGE): + """ + Return a |Section| object representing a new section added at the end + of the document. + """ + return self._document_part.add_section(start_type) + def add_table(self, rows, cols, style='LightShading-Accent1'): """ Add a table having row and column counts of *rows* and *cols* diff --git a/docx/parts/document.py b/docx/parts/document.py index 42c18c1fe..f2afa0d52 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -10,6 +10,7 @@ from collections import Sequence +from ..enum.section import WD_SECTION from ..opc.constants import RELATIONSHIP_TYPE as RT from ..opc.oxml import serialize_part_xml from ..opc.package import Part @@ -37,6 +38,13 @@ def add_paragraph(self): """ return self.body.add_paragraph() + def add_section(self, start_type=WD_SECTION.NEW_PAGE): + """ + Return a |Section| object representing a new section added at the end + of the document. + """ + raise NotImplementedError + def add_table(self, rows, cols): """ Return a table having *rows* rows and *cols* columns, newly appended diff --git a/tests/test_api.py b/tests/test_api.py index 9df3f4e6b..ad8c4dad1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -17,6 +17,7 @@ from docx.parts.document import DocumentPart, InlineShapes from docx.parts.numbering import NumberingPart from docx.parts.styles import StylesPart +from docx.section import Section from docx.table import Table from docx.text import Paragraph, Run @@ -96,6 +97,14 @@ def it_can_add_a_picture(self, add_picture_fixture): assert picture.height == expected_height assert picture is picture_ + def it_can_add_a_section(self, add_section_fixture): + document, start_type_, section_ = add_section_fixture + section = document.add_section(start_type_) + document._document_part.add_section.assert_called_once_with( + start_type_ + ) + assert section is section_ + def it_can_add_a_table(self, add_table_fixture): document, rows, cols, style, document_part_, expected_style, table_ = ( add_table_fixture @@ -200,6 +209,10 @@ def add_picture_fixture( expected_width, expected_height, picture_ ) + @pytest.fixture + def add_section_fixture(self, document, start_type_, section_): + return document, start_type_, section_ + @pytest.fixture def add_styled_paragraph_fixture(self, document, p_): style = 'foobaresque' @@ -269,11 +282,13 @@ def document(self, open_): return Document() @pytest.fixture - def document_part_(self, request, p_, paragraphs_, table_, tables_): + def document_part_( + self, request, p_, paragraphs_, section_, table_, tables_): document_part_ = instance_mock( request, DocumentPart, content_type=CT.WML_DOCUMENT_MAIN ) document_part_.add_paragraph.return_value = p_ + document_part_.add_section.return_value = section_ document_part_.add_table.return_value = table_ document_part_.paragraphs = paragraphs_ document_part_.tables = tables_ @@ -336,6 +351,14 @@ def paragraphs_(self, request): def r_(self, request): return instance_mock(request, Run) + @pytest.fixture + def section_(self, request): + return instance_mock(request, Section) + + @pytest.fixture + def start_type_(self, request): + return instance_mock(request, int) + @pytest.fixture def StylesPart_(self, request, styles_part_): StylesPart_ = class_mock(request, 'docx.api.StylesPart') From 56c31be447c2c8d6759907e69f4a474a3f0849d2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 22 Jun 2014 02:10:51 -0700 Subject: [PATCH 111/809] test: reorg fixtures in test_document.py --- tests/parts/test_document.py | 118 ++++++++++++++++++----------------- 1 file changed, 60 insertions(+), 58 deletions(-) diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index e1b11278d..4d597f9a2 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -152,6 +152,66 @@ def add_table_fixture(self, document_part_body_, body_, table_): rows, cols = 2, 4 return document_part, rows, cols, body_, table_ + @pytest.fixture + def document_blob_fixture(self, request, serialize_part_xml_): + document_elm = instance_mock(request, CT_Document) + document_part = DocumentPart(None, None, document_elm, None) + return document_part, document_elm, serialize_part_xml_ + + @pytest.fixture + def document_body_fixture(self, request, _Body_): + 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, _Body_, body_elm + + @pytest.fixture + def inline_shapes_fixture(self, request, InlineShapes_): + 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 + + @pytest.fixture(params=[ + ((), 1), ((1,), 2), ((2,), 1), ((1, 2, 3), 4), ((1, 2, 4), 3), + ((0, 0), 1), ((0, 0, 1, 3), 2), (('foo', 1, 2), 3), ((1, 'bar'), 2) + ]) + def next_id_fixture(self, request): + existing_ids, expected_id = request.param + document_elm = a_document().with_nsdecls().element + for n in existing_ids: + p = a_p().with_nsdecls().element + p.set('id', str(n)) + document_elm.append(p) + document = DocumentPart(None, None, document_elm, None) + return document, expected_id + + @pytest.fixture + def paragraphs_fixture(self, document_part_body_, body_, paragraphs_): + document_part = DocumentPart(None, None, None, None) + body_.paragraphs = paragraphs_ + return document_part, paragraphs_ + + @pytest.fixture + def sections_fixture(self, request, Sections_): + document_elm = a_document().with_nsdecls().element + document = DocumentPart(None, None, document_elm, None) + return document, document_elm, Sections_ + + @pytest.fixture + def tables_fixture(self, document_part_body_, body_, tables_): + document_part = DocumentPart(None, None, None, None) + body_.tables = tables_ + return document_part, tables_ + + # fixture components --------------------------------------------- + @pytest.fixture def _Body_(self, request): return class_mock(request, 'docx.parts.document._Body') @@ -175,22 +235,6 @@ def content_type_(self, request): def document(self): return DocumentPart(None, None, None, None) - @pytest.fixture - def document_blob_fixture(self, request, serialize_part_xml_): - document_elm = instance_mock(request, CT_Document) - document_part = DocumentPart(None, None, document_elm, None) - return document_part, document_elm, serialize_part_xml_ - - @pytest.fixture - def document_body_fixture(self, request, _Body_): - 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, _Body_, body_elm - @pytest.fixture def document_part_(self, request): return instance_mock(request, DocumentPart) @@ -238,30 +282,6 @@ def init(self, request): def InlineShapes_(self, request): return class_mock(request, 'docx.parts.document.InlineShapes') - @pytest.fixture - def inline_shapes_fixture(self, request, InlineShapes_): - 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 - - @pytest.fixture(params=[ - ((), 1), ((1,), 2), ((2,), 1), ((1, 2, 3), 4), ((1, 2, 4), 3), - ((0, 0), 1), ((0, 0, 1, 3), 2), (('foo', 1, 2), 3), ((1, 'bar'), 2) - ]) - def next_id_fixture(self, request): - existing_ids, expected_id = request.param - document_elm = a_document().with_nsdecls().element - for n in existing_ids: - p = a_p().with_nsdecls().element - p.set('id', str(n)) - document_elm.append(p) - document = DocumentPart(None, None, document_elm, None) - return document, expected_id - @pytest.fixture def parse_xml_(self, request): return function_mock(request, 'docx.parts.document.parse_xml') @@ -278,12 +298,6 @@ def package_(self, request): def paragraphs_(self, request): return instance_mock(request, list) - @pytest.fixture - def paragraphs_fixture(self, document_part_body_, body_, paragraphs_): - document_part = DocumentPart(None, None, None, None) - body_.paragraphs = paragraphs_ - return document_part, paragraphs_ - @pytest.fixture def part_load_fixture( self, document_part_load_, partname_, blob_, package_, @@ -311,12 +325,6 @@ def rId_(self, request): def Sections_(self, request): return class_mock(request, 'docx.parts.document.Sections') - @pytest.fixture - def sections_fixture(self, request, Sections_): - document_elm = a_document().with_nsdecls().element - document = DocumentPart(None, None, document_elm, None) - return document, document_elm, Sections_ - @pytest.fixture def serialize_part_xml_(self, request): return function_mock( @@ -331,12 +339,6 @@ def table_(self, request): def tables_(self, request): return instance_mock(request, list) - @pytest.fixture - def tables_fixture(self, document_part_body_, body_, tables_): - document_part = DocumentPart(None, None, None, None) - body_.tables = tables_ - return document_part, tables_ - class Describe_Body(object): From f4e62575e86deecb1e053bd6e196adde5ff7be01 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 22 Jun 2014 00:32:52 -0700 Subject: [PATCH 112/809] sect: add DocumentPart.add_section() Also, reorder fixtures in test_document.py --- docx/oxml/parts/document.py | 10 ++++++++ docx/parts/document.py | 4 ++- tests/parts/test_document.py | 48 ++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/docx/oxml/parts/document.py b/docx/oxml/parts/document.py index 59c1fa165..abcd2fd15 100644 --- a/docx/oxml/parts/document.py +++ b/docx/oxml/parts/document.py @@ -33,6 +33,16 @@ class CT_Body(BaseOxmlElement): p = ZeroOrMore('w:p', successors=('w:sectPr',)) tbl = ZeroOrMore('w:tbl', successors=('w:sectPr',)) + def add_section_break(self): + """ + Return the current ```` element after adding a clone of it + in a new ```` element appended to the block content elements. + Note that the "current" ```` will always be the sentinel + sectPr in this case since we're always working at the end of the + block content. + """ + raise NotImplementedError + def _insert_p(self, p): return self._append_blocklevelelt(p) diff --git a/docx/parts/document.py b/docx/parts/document.py index f2afa0d52..de093a3bb 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -43,7 +43,9 @@ def add_section(self, start_type=WD_SECTION.NEW_PAGE): Return a |Section| object representing a new section added at the end of the document. """ - raise NotImplementedError + new_sectPr = self._element.body.add_section_break() + new_sectPr.start_type = start_type + return Section(new_sectPr) def add_table(self, rows, cols): """ diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 4d597f9a2..fbd799c8f 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -14,6 +14,7 @@ from docx.opc.package import PartFactory from docx.opc.packuri import PackURI from docx.oxml.parts.document import CT_Body, CT_Document +from docx.oxml.section import CT_SectPr from docx.oxml.text import CT_R from docx.package import ImageParts, Package from docx.parts.document import _Body, DocumentPart, InlineShapes, Sections @@ -76,6 +77,15 @@ def it_can_add_a_paragraph(self, add_paragraph_fixture): body_.add_paragraph.assert_called_once_with() assert p is p_ + def it_can_add_a_section(self, add_section_fixture): + (document_part, start_type_, body_elm_, new_sectPr_, Section_, + section_) = add_section_fixture + section = document_part.add_section(start_type_) + body_elm_.add_section_break.assert_called_once_with() + assert new_sectPr_.start_type == start_type_ + Section_.assert_called_once_with(new_sectPr_) + assert section is section_ + def it_can_add_a_table(self, add_table_fixture): document_part, rows, cols, body_, table_ = add_table_fixture table = document_part.add_table(rows, cols) @@ -146,6 +156,16 @@ def add_paragraph_fixture(self, document_part_body_, body_, p_): document_part = DocumentPart(None, None, None, None) return document_part, body_, p_ + @pytest.fixture + def add_section_fixture( + self, document_elm_, start_type_, body_elm_, sectPr_, Section_, + section_): + document_part = DocumentPart(None, None, document_elm_, None) + return ( + document_part, start_type_, body_elm_, sectPr_, Section_, + section_ + ) + @pytest.fixture def add_table_fixture(self, document_part_body_, body_, table_): document_part = DocumentPart(None, None, None, None) @@ -223,6 +243,12 @@ def body_(self, request, p_, table_): body_.add_table.return_value = table_ return body_ + @pytest.fixture + def body_elm_(self, request, sectPr_): + body_elm_ = instance_mock(request, CT_Body) + body_elm_.add_section_break.return_value = sectPr_ + return body_elm_ + @pytest.fixture def blob_(self, request): return instance_mock(request, str) @@ -235,6 +261,10 @@ def content_type_(self, request): def document(self): return DocumentPart(None, None, None, None) + @pytest.fixture + def document_elm_(self, request, body_elm_): + return instance_mock(request, CT_Document, body=body_elm_) + @pytest.fixture def document_part_(self, request): return instance_mock(request, DocumentPart) @@ -321,16 +351,34 @@ def relate_to_(self, request, rId_): def rId_(self, request): return instance_mock(request, str) + @pytest.fixture + def Section_(self, request, section_): + return class_mock( + request, 'docx.parts.document.Section', return_value=section_ + ) + + @pytest.fixture + def section_(self, request): + return instance_mock(request, Section) + @pytest.fixture def Sections_(self, request): return class_mock(request, 'docx.parts.document.Sections') + @pytest.fixture + def sectPr_(self, request): + return instance_mock(request, CT_SectPr) + @pytest.fixture def serialize_part_xml_(self, request): return function_mock( request, 'docx.parts.document.serialize_part_xml' ) + @pytest.fixture + def start_type_(self, request): + return instance_mock(request, int) + @pytest.fixture def table_(self, request): return instance_mock(request, Table) From b0ddc5a50267ddc0f183cd53e468ebb6a853715a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 22 Jun 2014 01:08:56 -0700 Subject: [PATCH 113/809] oxml: add CT_Body.sectPr Remove now-dead CT_Body._sentinel_sectPr. --- docx/oxml/parts/document.py | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/docx/oxml/parts/document.py b/docx/oxml/parts/document.py index abcd2fd15..84ba2b4fc 100644 --- a/docx/oxml/parts/document.py +++ b/docx/oxml/parts/document.py @@ -5,7 +5,6 @@ . """ -from ..ns import qn from ..table import CT_Tbl from ..xmlchemy import BaseOxmlElement, ZeroOrOne, ZeroOrMore @@ -32,6 +31,7 @@ class CT_Body(BaseOxmlElement): """ p = ZeroOrMore('w:p', successors=('w:sectPr',)) tbl = ZeroOrMore('w:tbl', successors=('w:sectPr',)) + sectPr = ZeroOrOne('w:sectPr', successors=()) def add_section_break(self): """ @@ -57,7 +57,7 @@ def clear_content(self): Remove all content child elements from this element. Leave the element if it is present. """ - if self._sentinel_sectPr is not None: + if self.sectPr is not None: content_elms = self[:-1] else: content_elms = self[:] @@ -69,25 +69,9 @@ def _append_blocklevelelt(self, block_level_elt): Return *block_level_elt* after appending it to end of EG_BlockLevelElts sequence. """ - sentinel_sectPr = self._sentinel_sectPr - if sentinel_sectPr is not None: - sentinel_sectPr.addprevious(block_level_elt) + sectPr = self.sectPr + if sectPr is not None: + sectPr.addprevious(block_level_elt) else: self.append(block_level_elt) return block_level_elt - - @property - def _sentinel_sectPr(self): - """ - Return ```` element appearing as last child, or None if not - found. Note that the ```` element can also occur earlier in - the body; here we're only interested in one occuring as the last - child. - """ - if len(self) == 0: - sentinel_sectPr = None - elif self[-1].tag != qn('w:sectPr'): - sentinel_sectPr = None - else: - sentinel_sectPr = self[-1] - return sentinel_sectPr From e464e22106cdabead2a809d289b903b4d1f83984 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 22 Jun 2014 01:44:50 -0700 Subject: [PATCH 114/809] sect: add CT_Body.add_section_break() Removed CT_Body.add_p() test made redundant by xmlchemy. --- docx/oxml/parts/document.py | 6 +++- docx/oxml/section.py | 14 +++++++++ docx/oxml/text.py | 10 ++++++ features/doc-add-section.feature | 1 - tests/oxml/parts/test_document.py | 52 ++++++++++++++++++------------- 5 files changed, 60 insertions(+), 23 deletions(-) diff --git a/docx/oxml/parts/document.py b/docx/oxml/parts/document.py index 84ba2b4fc..b64b71d05 100644 --- a/docx/oxml/parts/document.py +++ b/docx/oxml/parts/document.py @@ -41,7 +41,11 @@ def add_section_break(self): sectPr in this case since we're always working at the end of the block content. """ - raise NotImplementedError + sentinel_sectPr = self.get_or_add_sectPr() + cloned_sectPr = sentinel_sectPr.clone() + p = self.add_p() + p.set_sectPr(cloned_sectPr) + return sentinel_sectPr def _insert_p(self, p): return self._append_blocklevelelt(p) diff --git a/docx/oxml/section.py b/docx/oxml/section.py index a5aa9dc36..cf76b67ed 100644 --- a/docx/oxml/section.py +++ b/docx/oxml/section.py @@ -4,6 +4,10 @@ Section-related custom element classes. """ +from __future__ import absolute_import, print_function + +from copy import deepcopy + from ..enum.section import WD_ORIENTATION, WD_SECTION_START from .simpletypes import ST_SignedTwipsMeasure, ST_TwipsMeasure from .xmlchemy import BaseOxmlElement, OptionalAttribute, ZeroOrOne @@ -71,6 +75,16 @@ def bottom_margin(self, value): pgMar = self.get_or_add_pgMar() pgMar.bottom = value + def clone(self): + """ + Return an exact duplicate of this ```` element tree + suitable for use in adding a section break. All rsid* attributes are + removed from the root ```` element. + """ + clone_sectPr = deepcopy(self) + clone_sectPr.attrib.clear() + return clone_sectPr + @property def footer(self): """ diff --git a/docx/oxml/text.py b/docx/oxml/text.py index b31f03057..e67944e89 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -32,6 +32,15 @@ def _insert_pPr(self, pPr): self.insert(0, pPr) return pPr + def set_sectPr(self, sectPr): + """ + Unconditionally replace or add *sectPr* as a grandchild in the + correct sequence. + """ + pPr = self.get_or_add_pPr() + pPr._remove_sectPr() + pPr._insert_sectPr(sectPr) + @property def style(self): """ @@ -70,6 +79,7 @@ class CT_PPr(BaseOxmlElement): ) pStyle = ZeroOrOne('w:pStyle') numPr = ZeroOrOne('w:numPr', successors=__child_sequence__[7:]) + sectPr = ZeroOrOne('w:sectPr', successors=('w:pPrChange',)) def _insert_pStyle(self, pStyle): self.insert(0, pStyle) diff --git a/features/doc-add-section.feature b/features/doc-add-section.feature index 274ab7b61..9b1c6e30c 100644 --- a/features/doc-add-section.feature +++ b/features/doc-add-section.feature @@ -4,7 +4,6 @@ Feature: Add a document section I need a way to add a new section to a document - @wip Scenario: Add a landscape section to a portrait document Given a single-section document having portrait layout When I add an even-page section to the document diff --git a/tests/oxml/parts/test_document.py b/tests/oxml/parts/test_document.py index eb3696ffe..646439313 100644 --- a/tests/oxml/parts/test_document.py +++ b/tests/oxml/parts/test_document.py @@ -4,33 +4,17 @@ Test suite for the docx.oxml.parts module. """ -from docx.oxml.text import CT_P +from __future__ import absolute_import, print_function, unicode_literals + +import pytest from .unitdata.document import a_body -from ..unitdata.text import a_p, a_sectPr +from ..unitdata.section import a_type +from ..unitdata.text import a_p, a_pPr, a_sectPr class DescribeCT_Body(object): - def it_can_add_a_p_to_itself(self): - """ - Return a newly created |CT_P| element that has been added after any - existing content. - """ - cases = ( - (a_body().with_nsdecls(), - a_body().with_nsdecls().with_child(a_p())), - (a_body().with_nsdecls().with_child(a_sectPr()), - a_body().with_nsdecls().with_child(a_p()).with_child(a_sectPr())), - ) - for before_body_bldr, after_body_bldr in cases: - body = before_body_bldr.element - # exercise ----------------- - p = body.add_p() - # verify ------------------- - assert body.xml == after_body_bldr.xml() - assert isinstance(p, CT_P) - def it_can_clear_all_the_content_it_holds(self): """ Remove all content child elements from this element. @@ -51,3 +35,29 @@ def it_can_clear_all_the_content_it_holds(self): body.clear_content() # verify ------------------- assert body.xml == after_body_bldr.xml() + + def it_can_add_a_section_break(self, section_break_fixture): + body, expected_xml = section_break_fixture + sectPr = body.add_section_break() + assert body.xml == expected_xml + assert sectPr is body.get_or_add_sectPr() + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def section_break_fixture(self): + body = ( + a_body().with_nsdecls().with_child( + a_sectPr().with_child( + a_type().with_val('foobar'))) + ).element + expected_xml = ( + a_body().with_nsdecls().with_child( + a_p().with_child( + a_pPr().with_child( + a_sectPr().with_child( + a_type().with_val('foobar'))))).with_child( + a_sectPr().with_child( + a_type().with_val('foobar'))) + ).xml() + return body, expected_xml From 164b4af57af54a7de84007ea6c156d03d2c150f1 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 22 Jun 2014 14:02:03 -0700 Subject: [PATCH 115/809] docs: add user documentation for sections --- docs/api/document.rst | 14 ++- docs/api/enum/WdOrientation.rst | 23 +++++ docs/api/enum/WdSectionStart.rst | 32 +++++++ docs/api/enum/index.rst | 2 + docs/api/section.rst | 18 ++++ docs/conf.py | 4 +- docs/dev/analysis/features/sections.rst | 48 ---------- docs/index.rst | 2 + docs/user/sections.rst | 122 ++++++++++++++++++++++++ docx/api.py | 4 +- docx/enum/section.py | 18 ++++ docx/parts/document.py | 2 +- docx/section.py | 10 +- 13 files changed, 242 insertions(+), 57 deletions(-) create mode 100644 docs/api/enum/WdOrientation.rst create mode 100644 docs/api/enum/WdSectionStart.rst create mode 100644 docs/api/section.rst create mode 100644 docs/user/sections.rst diff --git a/docs/api/document.rst b/docs/api/document.rst index e8edb9d46..accab05b3 100644 --- a/docs/api/document.rst +++ b/docs/api/document.rst @@ -4,7 +4,7 @@ Document objects ================ -... things having to do with Document objects ... +The main Document and related objects. .. currentmodule:: docx.api @@ -16,3 +16,15 @@ Document objects .. autoclass:: Document :members: + :exclude-members: numbering_part, styles_part + + +.. currentmodule:: docx.parts.document + + +|Sections| objects +------------------ + + +.. autoclass:: Sections + :members: diff --git a/docs/api/enum/WdOrientation.rst b/docs/api/enum/WdOrientation.rst new file mode 100644 index 000000000..1bb799146 --- /dev/null +++ b/docs/api/enum/WdOrientation.rst @@ -0,0 +1,23 @@ +.. _WdOrientation: + +``WD_ORIENTATION`` +================== + +alias: **WD_ORIENT** + +Specifies the page layout orientation. + +Example:: + + from docx.enum.section import WD_ORIENT + + section = document.sections[-1] + section.orientation = WD_ORIENT.LANDSCAPE + +---- + +PORTRAIT + Portrait orientation. + +LANDSCAPE + Landscape orientation. diff --git a/docs/api/enum/WdSectionStart.rst b/docs/api/enum/WdSectionStart.rst new file mode 100644 index 000000000..974e7ea32 --- /dev/null +++ b/docs/api/enum/WdSectionStart.rst @@ -0,0 +1,32 @@ +.. _WdSectionStart: + +``WD_SECTION_START`` +==================== + +alias: **WD_SECTION** + +Specifies the start type of a section break. + +Example:: + + from docx.enum.section import WD_SECTION + + section = document.sections[0] + section.start_type = WD_SECTION.NEW_PAGE + +---- + +CONTINUOUS + Continuous section break. + +NEW_COLUMN + New column section break. + +NEW_PAGE + New page section break. + +EVEN_PAGE + Even pages section break. + +ODD_PAGE + Section begins on next odd page. diff --git a/docs/api/enum/index.rst b/docs/api/enum/index.rst index d69c7d00e..6c307272c 100644 --- a/docs/api/enum/index.rst +++ b/docs/api/enum/index.rst @@ -8,4 +8,6 @@ can be found here: .. toctree:: :titlesonly: + WdOrientation + WdSectionStart WdUnderline diff --git a/docs/api/section.rst b/docs/api/section.rst new file mode 100644 index 000000000..478f80423 --- /dev/null +++ b/docs/api/section.rst @@ -0,0 +1,18 @@ + +.. _section_api: + +Section objects +=============== + +Provides access to section properties such as margins and page orientation. + + +.. currentmodule:: docx.section + + +|Section| objects +----------------- + + +.. autoclass:: Section + :members: diff --git a/docs/conf.py b/docs/conf.py index 13cba3cca..5fb91ca12 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -77,7 +77,7 @@ .. |_Columns| replace:: :class:`_Columns` -.. |Document| replace:: :class:`Document` +.. |Document| replace:: :class:`.Document` .. |docx| replace:: ``python-docx`` @@ -117,7 +117,7 @@ .. |Sections| replace:: :class:`.Sections` -.. |StylesPart| replace:: :class:`StylesPart` +.. |StylesPart| replace:: :class:`.StylesPart` .. |Table| replace:: :class:`.Table` diff --git a/docs/dev/analysis/features/sections.rst b/docs/dev/analysis/features/sections.rst index 6c1e37519..f57c0b4bf 100644 --- a/docs/dev/analysis/features/sections.rst +++ b/docs/dev/analysis/features/sections.rst @@ -32,54 +32,6 @@ occur: element is changed to reflect the type chosen by the user from the UI. -Candidate protocol ------------------- - -The following interactive session demonstrates the proposed protocol for -inserting a section break, illustrating adding a landscape section after an -existing portrait section:: - - >>> section_1 = document.sections[0] - >>> document.add_paragraph('This paragraph appears in section 1.') - >>> section_2 = document.add_section(WD_SECTION.EVEN_PAGE) - >>> section_2.orientation - PORTRAIT (0) - >>> section_2.orientation = WD_ORIENT.LANDSCAPE - >>> section_2.page_width = section_1.page_height - >>> section_2.page_height = section_1.page_width - >>> document.add_paragraph('This paragraph appears in section 2.') - - -The following interactive session demonstrates the proposed protocol for -setting section properties:: - - >>> sections = document.sections - >>> sections - - >>> len(sections) - 3 - >>> section = sections[-1] # the sentinel section - >>> section - - >>> section.section_start - WD_SECTION.CONTINUOUS (0) - >>> page_setup = section.page_setup - >>> page_setup - - >>> page_setup.page_width - 7772400 # Inches(8.5) - >>> page_setup.page_height - 10058400 # Inches(11) - >>> page_setup.orientation - WD_ORIENT.PORTRAIT - >>> page_setup.left_margin # and .right_, .top_, .bottom_ - 914400 - >>> page_setup.header_distance # and .footer_distance - 457200 # Inches(0.5) - >>> page_setup.gutter - 0 - - Word behavior ------------- diff --git a/docs/index.rst b/docs/index.rst index f751dcd16..a19a933f8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -67,6 +67,7 @@ User Guide user/install user/quickstart user/documents + user/sections user/api-concepts user/styles user/shapes @@ -82,6 +83,7 @@ API Documentation api/document api/table api/text + api/section api/shape api/shared api/enum/index diff --git a/docs/user/sections.rst b/docs/user/sections.rst new file mode 100644 index 000000000..6ab37015b --- /dev/null +++ b/docs/user/sections.rst @@ -0,0 +1,122 @@ +.. _sections: + +Working with Sections +===================== + +Word supports the notion of a *section*, a division of a document having the +same page layout settings, such as margins and page orientation. This is how, +for example, a document can contain some pages in portrait layout and others in +landscape. + +Most Word documents have only the single section that comes by default and +further, most of those have no reason to change the default margins or other +page layout. But when you *do* need to change the page layout, you'll need +to understand sections to get it done. + + +Accessing sections +------------------ + +Access to document sections is provided by the ``sections`` property on the +|Document| object:: + + >>> document = Document() + >>> sections = document.sections + >>> sections + + >>> len(sections) + 3 + >>> section = sections[0] + >>> section + + >>> for section in sections: + ... print(section.start_type) + ... + NEW_PAGE (2) + EVEN_PAGE (3) + ODD_PAGE (4) + +It's theoretically possible for a document not to have any explicit sections, +although I've yet to see this occur in the wild. If you're accessing an +unpredictable population of .docx files you may want to provide for that +possibility using a ``len()`` check or ``try`` block to avoid an uncaught +``IndexError`` exception stopping your program. + + +Adding a new section +-------------------- + +.. currentmodule:: docx.api + +The :meth:`Document.add_section` method allows a new section to be started at +the end of the document. Paragraphs and tables added after calling this method +will appear in the new section:: + + >>> current_section = document.section[-1] # last section in document + >>> current_section.start_type + NEW_PAGE (2) + >>> new_section = document.add_section(WD_SECTION.ODD_PAGE) + >>> new_section.start_type + ODD_PAGE (4) + + +Section properties +------------------ + +.. currentmodule:: docx.section + +The |Section| object has eleven properties that allow page layout settings to +be discovered and specified. + + +Section start type +~~~~~~~~~~~~~~~~~~ + +:attr:`Section.start_type` describes the type of break that precedes the +section:: + + >>> section.start_type + NEW_PAGE (2) + >>> section.start_type = WD_SECTION.ODD_PAGE + >>> section.start_type + ODD_PAGE (4) + +Values of ``start_type`` are members of the :ref:`WdSectionStart` enumeration. + + +Page dimensions and orientation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Three properties on |Section| describe page dimensions and orientation. +Together these can be used, for example, to change the orientation of a section +from portrait to landscape:: + + >>> section.orientation, section.page_width, section.page_height + (PORTRAIT (0), 7772400, 10058400) # (Inches(8.5), Inches(11)) + >>> new_width, new_height = section.page_height, section.page_width + >>> section.orientation = WD_ORIENT.LANDSCAPE + >>> section.page_width = new_width + >>> section.page_height = new_height + >>> section.orientation, section.page_width, section.page_height + (LANDSCAPE (1), 10058400, 7772400) + + +Page margins +~~~~~~~~~~~~ + +Seven properties on |Section| together specify the various edge spacings that +determine where text appears on the page:: + + >>> from docx.shared import Inches + >>> section.left_margin, section.right_margin + (1143000, 1143000) # (Inches(1.25), Inches(1.25)) + >>> section.top_margin, section.bottom_margin + (914400, 914400) # (Inches(1), Inches(1)) + >>> section.gutter + 0 + >>> section.header_distance, section.footer_distance + (457200, 457200) # (Inches(0.5), Inches(0.5)) + >>> section.left_margin = Inches(1.5) + >>> section.right_margin = Inches(1) + >>> section.left_margin, section.right_margin + (1371600, 914400) diff --git a/docx/api.py b/docx/api.py index a1c109fb4..7d268af8b 100644 --- a/docx/api.py +++ b/docx/api.py @@ -104,7 +104,9 @@ def add_picture(self, image_path_or_stream, width=None, height=None): def add_section(self, start_type=WD_SECTION.NEW_PAGE): """ Return a |Section| object representing a new section added at the end - of the document. + of the document. The optional *start_type* argument must be a member + of the :ref:`WdSectionStart` enumeration defaulting to + ``WD_SECTION.NEW_PAGE`` if not provided. """ return self._document_part.add_section(start_type) diff --git a/docx/enum/section.py b/docx/enum/section.py index 27c163982..b16ddbe72 100644 --- a/docx/enum/section.py +++ b/docx/enum/section.py @@ -12,7 +12,16 @@ @alias('WD_ORIENT') class WD_ORIENTATION(XmlEnumeration): """ + alias: **WD_ORIENT** + Specifies the page layout orientation. + + Example:: + + from docx.enum.section import WD_ORIENT + + section = document.sections[-1] + section.orientation = WD_ORIENT.LANDSCAPE """ __ms_name__ = 'WdOrientation' @@ -32,7 +41,16 @@ class WD_ORIENTATION(XmlEnumeration): @alias('WD_SECTION') class WD_SECTION_START(XmlEnumeration): """ + alias: **WD_SECTION** + Specifies the start type of a section break. + + Example:: + + from docx.enum.section import WD_SECTION + + section = document.sections[0] + section.start_type = WD_SECTION.NEW_PAGE """ __ms_name__ = 'WdSectionStart' diff --git a/docx/parts/document.py b/docx/parts/document.py index de093a3bb..3e1449491 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -239,7 +239,7 @@ def _inline_lst(self): class Sections(Sequence): """ Sequence of |Section| objects corresponding to the sections in the - document. + document. Supports ``len()``, iteration, and indexed access. """ def __init__(self, document_elm): super(Sections, self).__init__() diff --git a/docx/section.py b/docx/section.py index 69f57788d..0bdcd17dd 100644 --- a/docx/section.py +++ b/docx/section.py @@ -82,7 +82,8 @@ def left_margin(self, value): @property def orientation(self): """ - Page orientation for this section, one of ``WD_ORIENT.PORTRAIT`` or + Member of the :ref:`WdOrientation` enumeration specifying the page + orientation for this section, one of ``WD_ORIENT.PORTRAIT`` or ``WD_ORIENT.LANDSCAPE``. """ return self._sectPr.orientation @@ -134,9 +135,10 @@ def right_margin(self, value): @property def start_type(self): """ - The member of the ``WD_SECTION`` enumeration corresponding to the - initial break behavior of this section, e.g. ``WD_SECTION.ODD_PAGE`` - if the section should begin on the next odd page. + The member of the :ref:`WdSectionStart` enumeration corresponding to + the initial break behavior of this section, e.g. + ``WD_SECTION.ODD_PAGE`` if the section should begin on the next odd + page. """ return self._sectPr.start_type From f74dcd25433183982b8b194697cd63e2c777069d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 22 Jun 2014 15:11:13 -0700 Subject: [PATCH 116/809] release: prepare v0.6.0 release --- HISTORY.rst | 9 +++++++++ README.rst | 2 ++ docs/index.rst | 3 ++- docx/__init__.py | 2 +- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 6c371c336..5f60b4227 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,15 @@ Release History --------------- +0.6.0 (2014-06-22) +++++++++++++++++++ + +- Add feature #15: section page size +- Add feature #66: add section +- Add page margins and page orientation properties on Section +- Major refactoring of oxml layer + + 0.5.3 (2014-05-10) ++++++++++++++++++ diff --git a/README.rst b/README.rst index aeae3ebf6..82d1f0bd7 100644 --- a/README.rst +++ b/README.rst @@ -1,3 +1,5 @@ +.. image:: https://travis-ci.org/python-openxml/python-docx.svg?branch=master + :target: https://travis-ci.org/python-openxml/python-docx *python-docx* is a Python library for creating and updating Microsoft Word (.docx) files. diff --git a/docs/index.rst b/docs/index.rst index a19a933f8..a79fa9644 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,7 +4,8 @@ python-docx Release v\ |version| (:ref:`Installation `) -.. include:: ../README.rst +*python-docx* is a Python library for creating and updating Microsoft Word +(.docx) files. What it can do diff --git a/docx/__init__.py b/docx/__init__.py index b6147da04..1bba46ad5 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.5.3' +__version__ = '0.6.0' # register custom Part classes with opc package reader From 762cd2cd31a5101721b2efc2f489e9d7121b875a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 22 Jun 2014 17:17:03 -0700 Subject: [PATCH 117/809] test: add Python 3.4 compatibility * remove 3.2 compatibility claim from setup.py * add 3.4 environment to tox.ini * add version-dependent mock imports, becomes unittest.mock from 3.3 forward. Put all the imports in tests.unittest and import locally from there to centralize version-detection logic. --- .travis.yml | 1 + setup.py | 2 +- tests/image/test_jpeg.py | 4 +--- tests/image/test_png.py | 5 ++--- tests/image/test_tiff.py | 6 ++---- tests/opc/test_package.py | 6 ++---- tests/opc/test_phys_pkg.py | 3 +-- tests/opc/test_pkgreader.py | 6 ++---- tests/opc/test_pkgwriter.py | 6 +++--- tests/opc/test_rels.py | 6 +++--- tests/parts/test_document.py | 4 +--- tests/test_text.py | 4 +--- tests/unitutil.py | 11 ++++++++++- tox.ini | 14 +++++++++++++- 14 files changed, 43 insertions(+), 35 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3f7fc00f9..3345ff24f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,6 @@ language: python python: + - "3.4" - "3.3" - "2.7" - "2.6" diff --git a/setup.py b/setup.py index d7517330a..a4b1b57a9 100644 --- a/setup.py +++ b/setup.py @@ -51,8 +51,8 @@ def text_of(relpath): 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.2', 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', 'Topic :: Office/Business :: Office Suites', 'Topic :: Software Development :: Libraries' ] diff --git a/tests/image/test_jpeg.py b/tests/image/test_jpeg.py index d8ad54d30..4caa61d57 100644 --- a/tests/image/test_jpeg.py +++ b/tests/image/test_jpeg.py @@ -8,8 +8,6 @@ import pytest -from mock import call - from docx.compat import BytesIO from docx.image.constants import JPEG_MARKER_CODE, MIME_TYPE from docx.image.helpers import BIG_ENDIAN, StreamReader @@ -20,7 +18,7 @@ from docx.image.tiff import Tiff from ..unitutil import ( - initializer_mock, class_mock, instance_mock, method_mock + call, class_mock, initializer_mock, instance_mock, method_mock ) diff --git a/tests/image/test_png.py b/tests/image/test_png.py index b4d82ed5a..45df3bd33 100644 --- a/tests/image/test_png.py +++ b/tests/image/test_png.py @@ -8,8 +8,6 @@ import pytest -from mock import call - from docx.compat import BytesIO from docx.image.constants import MIME_TYPE, PNG_CHUNK_TYPE from docx.image.exceptions import InvalidImageStreamError @@ -20,7 +18,8 @@ ) from ..unitutil import ( - function_mock, class_mock, initializer_mock, instance_mock, method_mock + call, class_mock, function_mock, initializer_mock, instance_mock, + method_mock ) diff --git a/tests/image/test_tiff.py b/tests/image/test_tiff.py index 0ccf73156..40b6d7da6 100644 --- a/tests/image/test_tiff.py +++ b/tests/image/test_tiff.py @@ -8,8 +8,6 @@ import pytest -from mock import call - from docx.compat import BytesIO from docx.image.constants import MIME_TYPE, TIFF_TAG from docx.image.helpers import BIG_ENDIAN, LITTLE_ENDIAN, StreamReader @@ -19,8 +17,8 @@ ) from ..unitutil import ( - function_mock, class_mock, initializer_mock, instance_mock, loose_mock, - method_mock + call, class_mock, function_mock, initializer_mock, instance_mock, + loose_mock, method_mock ) diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index 0f6930ab5..e14d43788 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -8,8 +8,6 @@ import pytest -from mock import call, Mock, patch, PropertyMock - from docx.opc.oxml import CT_Relationships from docx.opc.packuri import PACKAGE_URI, PackURI from docx.opc.package import ( @@ -19,8 +17,8 @@ from docx.opc.pkgreader import PackageReader from ..unitutil import ( - cls_attr_mock, class_mock, function_mock, instance_mock, loose_mock, - method_mock + call, class_mock, cls_attr_mock, function_mock, instance_mock, + loose_mock, method_mock, Mock, patch, PropertyMock ) diff --git a/tests/opc/test_phys_pkg.py b/tests/opc/test_phys_pkg.py index 0a7021ea0..53679c591 100644 --- a/tests/opc/test_phys_pkg.py +++ b/tests/opc/test_phys_pkg.py @@ -14,7 +14,6 @@ import hashlib import pytest -from mock import Mock from zipfile import ZIP_DEFLATED, ZipFile from docx.opc.exceptions import PackageNotFoundError @@ -23,7 +22,7 @@ _DirPkgReader, PhysPkgReader, PhysPkgWriter, _ZipPkgReader, _ZipPkgWriter ) -from ..unitutil import absjoin, class_mock, loose_mock, test_file_dir +from ..unitutil import absjoin, class_mock, loose_mock, Mock, test_file_dir test_docx_path = absjoin(test_file_dir, 'test.docx') diff --git a/tests/opc/test_pkgreader.py b/tests/opc/test_pkgreader.py index b9947b496..bc5b0eb08 100644 --- a/tests/opc/test_pkgreader.py +++ b/tests/opc/test_pkgreader.py @@ -8,8 +8,6 @@ import pytest -from mock import call, Mock, patch - from docx.opc.constants import ( CONTENT_TYPE as CT, RELATIONSHIP_TARGET_MODE as RTM ) @@ -22,8 +20,8 @@ from .unitdata.types import a_Default, a_Types, an_Override from ..unitutil import ( - initializer_mock, class_mock, function_mock, instance_mock, loose_mock, - method_mock + call, class_mock, function_mock, initializer_mock, instance_mock, + loose_mock, method_mock, Mock, patch ) diff --git a/tests/opc/test_pkgwriter.py b/tests/opc/test_pkgwriter.py index 741e66ac6..a6eccc3ef 100644 --- a/tests/opc/test_pkgwriter.py +++ b/tests/opc/test_pkgwriter.py @@ -6,8 +6,6 @@ import pytest -from mock import call, MagicMock, Mock, patch - from docx.opc.constants import CONTENT_TYPE as CT from docx.opc.package import Part from docx.opc.packuri import PackURI @@ -15,7 +13,9 @@ from docx.opc.pkgwriter import _ContentTypesItem, PackageWriter from .unitdata.types import a_Default, a_Types, an_Override -from ..unitutil import class_mock, instance_mock, method_mock +from ..unitutil import ( + call, class_mock, instance_mock, MagicMock, method_mock, Mock, patch +) class DescribePackageWriter(object): diff --git a/tests/opc/test_rels.py b/tests/opc/test_rels.py index 7d58a48a5..66b557940 100644 --- a/tests/opc/test_rels.py +++ b/tests/opc/test_rels.py @@ -8,14 +8,14 @@ import pytest -from mock import call, Mock, patch, PropertyMock - from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.opc.oxml import CT_Relationships from docx.opc.package import Part, _Relationship, Relationships from docx.opc.packuri import PackURI -from ..unitutil import class_mock, instance_mock, loose_mock +from ..unitutil import ( + call, class_mock, instance_mock, loose_mock, Mock, patch, PropertyMock +) class Describe_Relationship(object): diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index fbd799c8f..7821c4e11 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -8,8 +8,6 @@ import pytest -from mock import Mock - from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT from docx.opc.package import PartFactory from docx.opc.packuri import PackURI @@ -32,7 +30,7 @@ from ..oxml.unitdata.text import a_p, a_pPr, a_sectPr, an_r from ..unitutil import ( function_mock, class_mock, initializer_mock, instance_mock, loose_mock, - method_mock, property_mock + method_mock, Mock, property_mock ) diff --git a/tests/test_text.py b/tests/test_text.py index f615de6c4..a08410592 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -14,15 +14,13 @@ import pytest -from mock import call, Mock - from .oxml.unitdata.text import ( a_b, a_bCs, a_br, a_caps, a_cs, a_dstrike, a_p, a_shadow, a_smallCaps, a_snapToGrid, a_specVanish, a_strike, a_t, a_u, a_vanish, a_webHidden, an_emboss, an_i, an_iCs, an_imprint, an_oMath, a_noProof, an_outline, an_r, an_rPr, an_rStyle, an_rtl ) -from .unitutil import class_mock, instance_mock +from .unitutil import call, class_mock, instance_mock, Mock class DescribeParagraph(object): diff --git a/tests/unitutil.py b/tests/unitutil.py index 1fc7d7590..5c7aaf2e9 100644 --- a/tests/unitutil.py +++ b/tests/unitutil.py @@ -5,8 +5,17 @@ """ import os +import sys + +if sys.version_info >= (3, 3): + from unittest import mock # noqa + from unittest.mock import call, MagicMock # noqa + from unittest.mock import create_autospec, Mock, patch, PropertyMock +else: + import mock # noqa + from mock import call, MagicMock # noqa + from mock import create_autospec, Mock, patch, PropertyMock -from mock import create_autospec, Mock, patch, PropertyMock from docx.oxml.xmlchemy import serialize_for_reading diff --git a/tox.ini b/tox.ini index fd44ece2a..cc76cb70b 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ python_classes = Test Describe python_functions = it_ they_ [tox] -envlist = py26, py27, py33 +envlist = py26, py27, py33, py34 [testenv] deps = @@ -20,3 +20,15 @@ deps = commands = py.test -qx behave --format progress --stop --tags=-wip + +[testenv:py33] +deps = + behave + lxml + pytest + +[testenv:py34] +deps = + behave + lxml + pytest From 488a4ad2e7fb2b0d0f7e5851ad5d322f798251fc Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 24 Jun 2014 21:00:19 -0700 Subject: [PATCH 118/809] docs: document run content feature analysis --- docs/dev/analysis/features/run-content.rst | 86 ++++++++++++++++++++ docs/dev/analysis/index.rst | 1 + docs/dev/analysis/schema/ct_p.rst | 95 ++++++++++++++-------- 3 files changed, 146 insertions(+), 36 deletions(-) create mode 100644 docs/dev/analysis/features/run-content.rst diff --git a/docs/dev/analysis/features/run-content.rst b/docs/dev/analysis/features/run-content.rst new file mode 100644 index 000000000..29b3530dd --- /dev/null +++ b/docs/dev/analysis/features/run-content.rst @@ -0,0 +1,86 @@ + +Run-level content +================= + +A run is the object most closely associated with inline content; text, +pictures, and other items that are flowed between the block-item boundaries +within a paragraph. + +main content child elements: + +* +* +* +* + + +``Run.clear()`` design notes +---------------------------- + +Possible strategy: + +1. Insert new empty ```` element before +2. Move ```` child to new r element, if there is one +3. Delete initial ```` +4. Set self._r of Run to new r element +4. Return self + + +Schema excerpt +-------------- + +.. highlight:: xml + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index e3b6f2efe..a648304fc 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,7 @@ Feature Analysis .. toctree:: :maxdepth: 1 + features/run-content features/numbering features/underline features/char-style diff --git a/docs/dev/analysis/schema/ct_p.rst b/docs/dev/analysis/schema/ct_p.rst index 273318dea..da0c418c0 100644 --- a/docs/dev/analysis/schema/ct_p.rst +++ b/docs/dev/analysis/schema/ct_p.rst @@ -32,24 +32,7 @@ Schema excerpt - - - - - - - - - - - - - - - - - - + @@ -58,6 +41,38 @@ Schema excerpt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -80,22 +95,22 @@ Schema excerpt - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + @@ -104,7 +119,15 @@ Schema excerpt - - + + + + + + + + + + From cbf3d5cc489879be12bdf8913f0d5313a267c6f2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 24 Jun 2014 21:04:45 -0700 Subject: [PATCH 119/809] acpt: add par-insert-paragraph.feature Acceptance test for Paragraph.insert_paragraph_before() API method. --- features/par-insert-paragraph.feature | 13 ++++++++++ features/steps/paragraph.py | 36 ++++++++++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 features/par-insert-paragraph.feature diff --git a/features/par-insert-paragraph.feature b/features/par-insert-paragraph.feature new file mode 100644 index 000000000..8f41d2b01 --- /dev/null +++ b/features/par-insert-paragraph.feature @@ -0,0 +1,13 @@ +Feature: Insert a paragraph before or after a paragraph + In order to add new content in the middle of an existing document + As a developer using python-docx + I need a way to insert a paragraph relative to another paragraph + + + @wip + Scenario: Add a new paragraph above an existing paragraph + Given a document containing three paragraphs + When I insert a paragraph above the second paragraph + Then the document contains four paragraphs + And the text of the second paragraph matches the text I set + And the style of the second paragraph matches the style I set diff --git a/features/steps/paragraph.py b/features/steps/paragraph.py index ca1a98659..1646245ca 100644 --- a/features/steps/paragraph.py +++ b/features/steps/paragraph.py @@ -4,7 +4,7 @@ Step implementations for paragraph-related features """ -from behave import then, when +from behave import given, then, when from docx import Document @@ -14,6 +14,17 @@ TEST_STYLE = 'Heading1' +# given =================================================== + +@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') + context.document = document + + # when ==================================================== @when('I add a run to the paragraph') @@ -26,6 +37,12 @@ def when_add_new_text_to_run(context): context.r.add_text(test_text) +@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') + + @when('I set the paragraph style') def when_I_set_the_paragraph_style(context): context.paragraph.add_run().add_text(test_text) @@ -34,6 +51,11 @@ def when_I_set_the_paragraph_style(context): # then ===================================================== +@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') def then_document_contains_text_I_added(context): document = Document(saved_docx_path) @@ -47,3 +69,15 @@ def then_document_contains_text_I_added(context): def then_the_paragraph_has_the_style_I_set(context): paragraph = Document(saved_docx_path).paragraphs[-1] assert paragraph.style == TEST_STYLE + + +@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 == 'Heading1' + + +@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' From 4555d2abe8bf6367d633140cbdfa6168526f3251 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 24 Jun 2014 21:11:57 -0700 Subject: [PATCH 120/809] doc: small refactoring on Document.add_paragraph() Document.add_paragraph() can rely on Paragraph.add_run() to add the text. --- docx/api.py | 3 +-- tests/test_api.py | 9 ++++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/docx/api.py b/docx/api.py index 7d268af8b..16c5ba369 100644 --- a/docx/api.py +++ b/docx/api.py @@ -67,8 +67,7 @@ def add_paragraph(self, text='', style=None): """ p = self._document_part.add_paragraph() if text: - r = p.add_run() - r.add_text(text) + p.add_run(text) if style is not None: p.style = style return p diff --git a/tests/test_api.py b/tests/test_api.py index ad8c4dad1..77a6a69a1 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -70,10 +70,9 @@ def it_can_add_an_empty_paragraph(self, add_empty_paragraph_fixture): assert p is p_ def it_can_add_a_paragraph_of_text(self, add_text_paragraph_fixture): - document, text, p_, r_ = add_text_paragraph_fixture + document, text, p_ = add_text_paragraph_fixture p = document.add_paragraph(text) - p.add_run.assert_called_once_with() - r_.add_text.assert_called_once_with(text) + p.add_run.assert_called_once_with(text) def it_can_add_a_styled_paragraph(self, add_styled_paragraph_fixture): document, style, p_ = add_styled_paragraph_fixture @@ -228,9 +227,9 @@ def add_table_fixture(self, request, document, document_part_, table_): ) @pytest.fixture - def add_text_paragraph_fixture(self, document, p_, r_): + def add_text_paragraph_fixture(self, document, p_): text = 'foobar\rbarfoo' - return document, text, p_, r_ + return document, text, p_ @pytest.fixture def init_fixture(self, docx_, open_): From 6c122c0ee36c3765a40e15ba2c881501b8b9ee4c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 24 Jun 2014 21:33:30 -0700 Subject: [PATCH 121/809] doc: refactor some names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ‘p’ has generally come to be reserved for a element and ‘paragraph’ used for a Paragraph instance. Similaryly ‘r’ indicates a element and ‘run’ an instance of Run. --- docx/api.py | 8 ++--- tests/test_api.py | 78 +++++++++++++++++++++++++---------------------- 2 files changed, 46 insertions(+), 40 deletions(-) diff --git a/docx/api.py b/docx/api.py index 16c5ba369..345302558 100644 --- a/docx/api.py +++ b/docx/api.py @@ -65,12 +65,12 @@ 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*. """ - p = self._document_part.add_paragraph() + paragraph = self._document_part.add_paragraph() if text: - p.add_run(text) + paragraph.add_run(text) if style is not None: - p.style = style - return p + paragraph.style = style + return paragraph def add_picture(self, image_path_or_stream, width=None, height=None): """ diff --git a/tests/test_api.py b/tests/test_api.py index 77a6a69a1..0a22606a2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -52,10 +52,12 @@ def it_should_raise_if_not_a_Word_file(self, Package_, package_, docx_): Document._open(docx_) def it_can_add_a_heading(self, add_heading_fixture): - document, add_paragraph_, p_, text, level, style = add_heading_fixture - p = document.add_heading(text, level) + document, add_paragraph_, paragraph_, text, level, style = ( + add_heading_fixture + ) + paragraph = document.add_heading(text, level) add_paragraph_.assert_called_once_with(text, style) - assert p is p_ + assert paragraph is paragraph_ def it_should_raise_on_heading_level_out_of_range(self, document): with pytest.raises(ValueError): @@ -64,28 +66,28 @@ def it_should_raise_on_heading_level_out_of_range(self, document): document.add_heading(level=10) def it_can_add_an_empty_paragraph(self, add_empty_paragraph_fixture): - document, document_part_, p_ = add_empty_paragraph_fixture - p = document.add_paragraph() + document, document_part_, paragraph_ = add_empty_paragraph_fixture + paragraph = document.add_paragraph() document_part_.add_paragraph.assert_called_once_with() - assert p is p_ + assert paragraph is paragraph_ def it_can_add_a_paragraph_of_text(self, add_text_paragraph_fixture): - document, text, p_ = add_text_paragraph_fixture - p = document.add_paragraph(text) - p.add_run.assert_called_once_with(text) + document, text = add_text_paragraph_fixture + paragraph = document.add_paragraph(text) + paragraph.add_run.assert_called_once_with(text) def it_can_add_a_styled_paragraph(self, add_styled_paragraph_fixture): - document, style, p_ = add_styled_paragraph_fixture - p = document.add_paragraph(style=style) - assert p.style == style + document, style = add_styled_paragraph_fixture + paragraph = document.add_paragraph(style=style) + assert paragraph.style == style def it_can_add_a_page_break(self, add_page_break_fixture): - document, document_part_, p_, r_ = add_page_break_fixture - p = document.add_page_break() + document, document_part_, paragraph_, run_ = add_page_break_fixture + paragraph = document.add_page_break() document_part_.add_paragraph.assert_called_once_with() - p_.add_run.assert_called_once_with() - r_.add_break.assert_called_once_with(WD_BREAK.PAGE) - assert p is p_ + paragraph_.add_run.assert_called_once_with() + run_.add_break.assert_called_once_with(WD_BREAK.PAGE) + assert paragraph is paragraph_ def it_can_add_a_picture(self, add_picture_fixture): (document, image_path, width, height, inline_shapes_, expected_width, @@ -176,19 +178,22 @@ def it_creates_styles_part_on_first_access_if_not_present( # fixtures ------------------------------------------------------- @pytest.fixture - def add_empty_paragraph_fixture(self, document, document_part_, p_): - return document, document_part_, p_ + def add_empty_paragraph_fixture( + self, document, document_part_, paragraph_): + return document, document_part_, paragraph_ @pytest.fixture(params=[0, 1, 2, 5, 9]) - def add_heading_fixture(self, request, document, add_paragraph_, p_): + def add_heading_fixture( + self, request, document, add_paragraph_, paragraph_): level = request.param text = 'Spam vs. Bacon' style = 'Title' if level == 0 else 'Heading%d' % level - return document, add_paragraph_, p_, text, level, style + return document, add_paragraph_, paragraph_, text, level, style @pytest.fixture - def add_page_break_fixture(self, document, document_part_, p_, r_): - return document, document_part_, p_, r_ + def add_page_break_fixture( + self, document, document_part_, paragraph_, run_): + return document, document_part_, paragraph_, run_ @pytest.fixture(params=[ (None, None, 200, 100), @@ -213,9 +218,9 @@ def add_section_fixture(self, document, start_type_, section_): return document, start_type_, section_ @pytest.fixture - def add_styled_paragraph_fixture(self, document, p_): + def add_styled_paragraph_fixture(self, document): style = 'foobaresque' - return document, style, p_ + return document, style @pytest.fixture(params=[None, 'LightShading-Accent1', 'foobar']) def add_table_fixture(self, request, document, document_part_, table_): @@ -227,9 +232,9 @@ def add_table_fixture(self, request, document, document_part_, table_): ) @pytest.fixture - def add_text_paragraph_fixture(self, document, p_): + def add_text_paragraph_fixture(self, document): text = 'foobar\rbarfoo' - return document, text, p_ + return document, text @pytest.fixture def init_fixture(self, docx_, open_): @@ -261,9 +266,9 @@ def tables_fixture(self, document, tables_): # fixture components --------------------------------------------- @pytest.fixture - def add_paragraph_(self, request, p_): + def add_paragraph_(self, request, paragraph_): return method_mock( - request, Document, 'add_paragraph', return_value=p_ + request, Document, 'add_paragraph', return_value=paragraph_ ) @pytest.fixture @@ -282,11 +287,12 @@ def document(self, open_): @pytest.fixture def document_part_( - self, request, p_, paragraphs_, section_, table_, tables_): + self, request, paragraph_, paragraphs_, section_, table_, + tables_): document_part_ = instance_mock( request, DocumentPart, content_type=CT.WML_DOCUMENT_MAIN ) - document_part_.add_paragraph.return_value = p_ + document_part_.add_paragraph.return_value = paragraph_ document_part_.add_section.return_value = section_ document_part_.add_table.return_value = table_ document_part_.paragraphs = paragraphs_ @@ -325,10 +331,10 @@ def open_(self, request, document_part_, package_): ) @pytest.fixture - def p_(self, request, r_): - p_ = instance_mock(request, Paragraph) - p_.add_run.return_value = r_ - return p_ + def paragraph_(self, request, run_): + paragraph_ = instance_mock(request, Paragraph) + paragraph_.add_run.return_value = run_ + return paragraph_ @pytest.fixture def Package_(self, request, package_): @@ -347,7 +353,7 @@ def paragraphs_(self, request): return instance_mock(request, list) @pytest.fixture - def r_(self, request): + def run_(self, request): return instance_mock(request, Run) @pytest.fixture From 65db85311e9de6e50add607be169e57f8fcc7591 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 24 Jun 2014 21:52:05 -0700 Subject: [PATCH 122/809] para: add Paragraph.insert_paragraph_before() --- docx/oxml/text.py | 10 ++++++- docx/text.py | 14 ++++++++++ features/par-insert-paragraph.feature | 1 - tests/test_text.py | 38 ++++++++++++++++++++++++--- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index e67944e89..68d972038 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -9,7 +9,7 @@ from .ns import qn from .simpletypes import ST_BrClear, ST_BrType from .xmlchemy import ( - BaseOxmlElement, OptionalAttribute, ZeroOrMore, ZeroOrOne + BaseOxmlElement, OptionalAttribute, OxmlElement, ZeroOrMore, ZeroOrOne ) @@ -32,6 +32,14 @@ def _insert_pPr(self, pPr): self.insert(0, pPr) return pPr + def add_p_before(self): + """ + Return a new ```` element inserted directly prior to this one. + """ + new_p = OxmlElement('w:p') + self.addprevious(new_p) + return new_p + def set_sectPr(self, sectPr): """ Unconditionally replace or add *sectPr* as a grandchild in the diff --git a/docx/text.py b/docx/text.py index 11d5b15a7..d92b30e64 100644 --- a/docx/text.py +++ b/docx/text.py @@ -71,6 +71,20 @@ def add_run(self, text=None, style=None): run.style = style return run + def insert_paragraph_before(self, text=None, style=None): + """ + Return a newly created paragraph, inserted directly before this + paragraph, and having its text set to *text* and style set to + *style*. + """ + p = self._p.add_p_before() + paragraph = Paragraph(p) + if text: + paragraph.add_run(text) + if style is not None: + paragraph.style = style + return paragraph + @property def runs(self): """ diff --git a/features/par-insert-paragraph.feature b/features/par-insert-paragraph.feature index 8f41d2b01..432e32b02 100644 --- a/features/par-insert-paragraph.feature +++ b/features/par-insert-paragraph.feature @@ -4,7 +4,6 @@ Feature: Insert a paragraph before or after a paragraph I need a way to insert a paragraph relative to another paragraph - @wip Scenario: Add a new paragraph above an existing paragraph Given a document containing three paragraphs When I insert a paragraph above the second paragraph diff --git a/tests/test_text.py b/tests/test_text.py index a08410592..a504297a3 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -9,16 +9,18 @@ ) from docx.enum.text import WD_BREAK, WD_UNDERLINE +from docx.oxml.ns import qn from docx.oxml.text import CT_P, CT_R from docx.text import Paragraph, Run import pytest +from .oxml.parts.unitdata.document import a_body from .oxml.unitdata.text import ( - a_b, a_bCs, a_br, a_caps, a_cs, a_dstrike, a_p, a_shadow, a_smallCaps, - a_snapToGrid, a_specVanish, a_strike, a_t, a_u, a_vanish, a_webHidden, - an_emboss, an_i, an_iCs, an_imprint, an_oMath, a_noProof, an_outline, - an_r, an_rPr, an_rStyle, an_rtl + a_b, a_bCs, a_br, a_caps, a_cs, a_dstrike, a_p, a_pPr, a_pStyle, + a_shadow, a_smallCaps, a_snapToGrid, a_specVanish, a_strike, a_t, a_u, + a_vanish, a_webHidden, an_emboss, an_i, an_iCs, an_imprint, an_oMath, + a_noProof, an_outline, an_r, an_rPr, an_rStyle, an_rtl ) from .unitutil import call, class_mock, instance_mock, Mock @@ -62,6 +64,14 @@ def it_knows_the_text_it_contains(self, text_prop_fixture): p, expected_text = text_prop_fixture assert p.text == expected_text + def it_can_insert_a_paragraph_before_itself(self, insert_before_fixture): + paragraph, text, style, body, expected_xml = insert_before_fixture + new_paragraph = paragraph.insert_paragraph_before(text, style) + assert isinstance(new_paragraph, Paragraph) + assert new_paragraph.text == text + assert new_paragraph.style == style + assert body.xml == expected_xml + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -79,6 +89,26 @@ def add_run_fixture(self, request, paragraph): expected_xml = a_p().with_nsdecls().with_child(r_bldr).xml() return paragraph, text, style, expected_xml + @pytest.fixture + def insert_before_fixture(self): + text, style = 'foobar', 'Heading1' + body = ( + a_body().with_nsdecls().with_child( + a_p()) + ).element + p = body.find(qn('w:p')) + paragraph = Paragraph(p) + expected_xml = ( + a_body().with_nsdecls().with_child( + a_p().with_child( + a_pPr().with_child( + a_pStyle().with_val(style))).with_child( + an_r().with_child( + a_t().with_text(text)))).with_child( + a_p()) + ).xml() + return paragraph, text, style, body, expected_xml + # fixture components --------------------------------------------- @pytest.fixture From 1a05663d64f05bd3a81a9c6ca22b9d845234732c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 24 Jun 2014 22:40:55 -0700 Subject: [PATCH 123/809] docs: add user docs for insert_paragraph_before() --- docs/dev/analysis/features/run-content.rst | 2 +- docs/user/quickstart.rst | 70 ++++++++++++---------- docx/text.py | 5 +- 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/docs/dev/analysis/features/run-content.rst b/docs/dev/analysis/features/run-content.rst index 29b3530dd..c34ed01d0 100644 --- a/docs/dev/analysis/features/run-content.rst +++ b/docs/dev/analysis/features/run-content.rst @@ -23,7 +23,7 @@ Possible strategy: 2. Move ```` child to new r element, if there is one 3. Delete initial ```` 4. Set self._r of Run to new r element -4. Return self +5. Return self Schema excerpt diff --git a/docs/user/quickstart.rst b/docs/user/quickstart.rst index 39ef358a7..01c5f2729 100644 --- a/docs/user/quickstart.rst +++ b/docs/user/quickstart.rst @@ -16,9 +16,9 @@ First thing you'll need is a document to work on. The easiest way is this:: document = Document() This opens up a blank document based on the default "template", pretty much -what you get when you start a new document in Word using the built-in defaults. -You can open any Word document using |docx|, but we'll keep things simple for -the moment. +what you get when you start a new document in Word using the built-in +defaults. You can open and work on an existing Word document using |docx|, +but we'll keep things simple for the moment. Adding a paragraph @@ -29,13 +29,23 @@ headings and list items like bullets. Here's the simplest way to add one:: - p = document.add_paragraph('Lorem ipsum dolor sit amet.') + paragraph = document.add_paragraph('Lorem ipsum dolor sit amet.') -This method returns a reference to the newly added paragraph, assigned to ``p`` -in this case. I'll be leaving that out in the following examples unless I have -a need for it. In your code, often times you won't be doing anything with the -item after you've added it, so there's not a lot of sense in keep a reference -to it hanging around. +This method returns a reference to a paragraph, newly added paragraph at the +end of the document. The new paragraph reference is assigned to ``paragraph`` +in this case, but I'll be leaving that out in the following examples unless +I have a need for it. In your code, often times you won't be doing anything +with the item after you've added it, so there's not a lot of sense in keep +a reference to it hanging around. + +It's also possible to use one paragraph as a "cursor" and insert a new +paragraph directly above it:: + + prior_paragraph = paragraph.insert_paragraph_before('Lorem ipsum') + +This allows a paragraph to be inserted in the middle of a document, something +that's often important when modifying an existing document rather than +generating one from scratch. Adding a heading @@ -218,8 +228,8 @@ This particular style causes the paragraph to appear as a bullet, a very handy thing. You can also apply a style afterward. These two lines are equivalent to the one above:: - p = document.add_paragraph('Lorem ipsum dolor sit amet.') - p.style = 'ListBullet' + paragraph = document.add_paragraph('Lorem ipsum dolor sit amet.') + paragraph.style = 'ListBullet' The style is specified using its style ID, 'ListBullet' in this example. Generally, the style ID is formed by removing the spaces in the style name as @@ -248,8 +258,8 @@ When you add a paragraph by providing text to the ``.add_paragraph()`` method, it gets put into a single run. You can add more using the ``.add_run()`` method on the paragraph:: - p = document.add_paragraph('Lorem ipsum ') - p.add_run('dolor sit amet.') + paragraph = document.add_paragraph('Lorem ipsum ') + paragraph.add_run('dolor sit amet.') This produces a paragraph that looks just like one created from a single string. It's not apparent where paragraph text is broken into runs unless you @@ -261,33 +271,33 @@ that one a few times :). |Run| objects have both a ``.bold`` and ``.italic`` property that allows you to set their value for a run:: - p = document.add_paragraph('Lorem ipsum ') - r = p.add_run('dolor') - r.bold = True - p.add_run(' sit amet.') + paragraph = document.add_paragraph('Lorem ipsum ') + run = paragraph.add_run('dolor') + run.bold = True + paragraph.add_run(' sit amet.') which produces text that looks like this: 'Lorem ipsum **dolor** sit amet.' Note that you can set bold or italic right on the result of ``.add_run()`` if you don't need it for anything else:: - p.add_run('dolor').bold = True + paragraph.add_run('dolor').bold = True # is equivalent to: - r = p.add_run('dolor') - r.bold = True + run = paragraph.add_run('dolor') + run.bold = True - # except you don't have a reference to `r` afterward + # except you don't have a reference to `run` afterward It's not necessary to provide text to the ``.add_paragraph()`` method. This can make your code simpler if you're building the paragraph up from runs anyway:: - p = document.add_paragraph() - p.add_run('Lorem ipsum ') - p.add_run('dolor').bold = True - p.add_run(' sit amet.') + paragraph = document.add_paragraph() + paragraph.add_run('Lorem ipsum ') + paragraph.add_run('dolor').bold = True + paragraph.add_run(' sit amet.') Applying a character style @@ -302,15 +312,15 @@ Like paragraph styles, a character style must already be defined in the document A character style can be specified when adding a new run:: - p = document.add_paragraph('Normal text, ') - p.add_run('text with emphasis.', 'Emphasis') + paragraph = document.add_paragraph('Normal text, ') + paragraph.add_run('text with emphasis.', 'Emphasis') You can also apply a style to a run after it is created. This code produces the same result as the lines above:: - p = document.add_paragraph('Normal text, ') - r = p.add_run('text with emphasis.') - r.style = 'Emphasis' + paragraph = document.add_paragraph('Normal text, ') + run = paragraph.add_run('text with emphasis.') + run.style = 'Emphasis' As with a paragraph style, the style ID is formed by removing the spaces in the name as it appears in the Word UI. So the style 'Subtle Emphasis' would diff --git a/docx/text.py b/docx/text.py index d92b30e64..77d5969ed 100644 --- a/docx/text.py +++ b/docx/text.py @@ -74,8 +74,9 @@ def add_run(self, text=None, style=None): def insert_paragraph_before(self, text=None, style=None): """ Return a newly created paragraph, inserted directly before this - paragraph, and having its text set to *text* and style set to - *style*. + paragraph. If *text* is supplied, the new paragraph contains that + text in a single run. If *style* is provided, that style is assigned + to the new paragraph. """ p = self._p.add_p_before() paragraph = Paragraph(p) From 9c864625c658a2337c8c8992c979adc1777be1a7 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 24 Jun 2014 23:59:35 -0700 Subject: [PATCH 124/809] acpt: add run-clear-run.feature Scenario for Run.clear() API method. --- features/run-clear-run.feature | 12 ++++++++++++ features/steps/text.py | 24 ++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 features/run-clear-run.feature diff --git a/features/run-clear-run.feature b/features/run-clear-run.feature new file mode 100644 index 000000000..a7ed4392f --- /dev/null +++ b/features/run-clear-run.feature @@ -0,0 +1,12 @@ +Feature: Remove the content of a run + In order to edit the content of a run while preserving its formatting + As a developer using python-docx + I need a way to clear the content of a run + + + @wip + Scenario: Clear run content + Given a run having known text and formatting + When I clear the run + Then the run contains no text + But the run formatting is preserved diff --git a/features/steps/text.py b/features/steps/text.py index 7c46a2ad0..0350ed75f 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -30,6 +30,14 @@ def given_a_run_having_bool_prop_set_on(context, bool_prop_name): context.run = run +@given('a run having known text and formatting') +def given_a_run_having_known_text_and_formatting(context): + run = Document().add_paragraph().add_run('foobar') + run.bold = True + run.italic = True + context.run = run + + @given('a run having {underline_type} underline') def given_a_run_having_underline_type(context, underline_type): run_idx = { @@ -85,6 +93,11 @@ def when_assign_true_to_bool_run_prop(context, value_str, bool_prop_name): setattr(run, bool_prop_name, value) +@when('I clear the run') +def when_I_clear_the_run(context): + context.run.clear() + + @when('I set the character style of the run to {char_style}') def when_I_set_the_character_style_of_the_run(context, char_style): style_value = { @@ -151,11 +164,22 @@ def then_run_appears_without_bool_prop(context, boolean_prop_name): assert getattr(run, boolean_prop_name) is False +@then('the run contains no text') +def then_the_run_contains_no_text(context): + assert context.run.text == '' + + @then('the run contains the text I specified') def then_the_run_contains_the_text_I_specified(context): assert context.run.text == test_text +@then('the run formatting is preserved') +def then_the_run_formatting_is_preserved(context): + assert context.run.bold is True + assert context.run.italic is True + + @then('the run underline property value is {underline_value}') def then_the_run_underline_property_value_is(context, underline_value): expected_value = { From 30b79f5a805364195e54e923e80ad8431961911a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 25 Jun 2014 00:13:18 -0700 Subject: [PATCH 125/809] run: add Run.clear() --- docx/oxml/text.py | 8 ++++++++ docx/text.py | 8 ++++++++ features/run-clear-run.feature | 1 - tests/test_text.py | 24 ++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 68d972038..777896513 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -149,6 +149,14 @@ def add_drawing(self, inline_or_anchor): drawing.append(inline_or_anchor) return drawing + def clear_content(self): + """ + Remove all child elements except the ```` element if present. + """ + content_child_elms = self[1:] if self.rPr is not None else self[:] + for child in content_child_elms: + self.remove(child) + @property def style(self): """ diff --git a/docx/text.py b/docx/text.py index 77d5969ed..59630cc32 100644 --- a/docx/text.py +++ b/docx/text.py @@ -172,6 +172,14 @@ def bold(self): """ return 'b' + def clear(self): + """ + Return reference to this run after removing all its content. All run + formatting is preserved. + """ + self._r.clear_content() + return self + @boolproperty def complex_script(self): """ diff --git a/features/run-clear-run.feature b/features/run-clear-run.feature index a7ed4392f..fd3269b33 100644 --- a/features/run-clear-run.feature +++ b/features/run-clear-run.feature @@ -4,7 +4,6 @@ Feature: Remove the content of a run I need a way to clear the content of a run - @wip Scenario: Clear run content Given a run having known text and formatting When I clear the run diff --git a/tests/test_text.py b/tests/test_text.py index a504297a3..ab22ef11a 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -9,6 +9,7 @@ ) from docx.enum.text import WD_BREAK, WD_UNDERLINE +from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.oxml.text import CT_P, CT_R from docx.text import Paragraph, Run @@ -210,6 +211,13 @@ def it_knows_the_text_it_contains(self, text_prop_fixture): run, expected_text = text_prop_fixture assert run.text == expected_text + def it_can_remove_its_content_while_preserving_formatting( + self, clear_fixture): + run, expected_xml = clear_fixture + _run = run.clear() + assert run._r.xml == expected_xml + assert _run is run + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -366,6 +374,22 @@ def bool_prop_set_fixture(self, request): expected_xml = an_r().with_nsdecls().with_child(rPr_bldr).xml() return run, bool_prop_name, value, expected_xml + @pytest.fixture(params=[ + ('bi', 'foobar'), ('bi', None), ('', 'foobar'), ('', None) + ]) + def clear_fixture(self, request): + formatting, text = request.param + r = OxmlElement('w:r') + if 'b' in formatting: + r.get_or_add_rPr()._add_b().val = True + if 'i' in formatting: + r.get_or_add_rPr()._add_i().val = True + expected_xml = r.xml + if text is not None: + r.add_t(text) + run = Run(r) + return run, expected_xml + @pytest.fixture(params=['Foobar', None]) def style_get_fixture(self, request): style = request.param From f77a632ce4051c129e1b0cf10a48e88e7f4a8b4e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 25 Jun 2014 01:18:10 -0700 Subject: [PATCH 126/809] acpt: add run-add-content.feature With scenario for Run.add_tab() --- docs/dev/analysis/features/run-content.rst | 15 +++------------ features/run-add-content.feature | 11 +++++++++++ features/steps/text.py | 12 ++++++++++++ 3 files changed, 26 insertions(+), 12 deletions(-) create mode 100644 features/run-add-content.feature diff --git a/docs/dev/analysis/features/run-content.rst b/docs/dev/analysis/features/run-content.rst index c34ed01d0..9a615c5f7 100644 --- a/docs/dev/analysis/features/run-content.rst +++ b/docs/dev/analysis/features/run-content.rst @@ -12,18 +12,7 @@ main content child elements: * * * - - -``Run.clear()`` design notes ----------------------------- - -Possible strategy: - -1. Insert new empty ```` element before -2. Move ```` child to new r element, if there is one -3. Delete initial ```` -4. Set self._r of Run to new r element -5. Return self +* Schema excerpt @@ -84,3 +73,5 @@ Schema excerpt + + diff --git a/features/run-add-content.feature b/features/run-add-content.feature new file mode 100644 index 000000000..04f1eb1ff --- /dev/null +++ b/features/run-add-content.feature @@ -0,0 +1,11 @@ +Feature: Add content to a run + In order to populate a run with varied content + As a developer using python-docx + I need a way to add each of the run content elements to a run + + + @wip + Scenario: Add a tab + Given a run + When I add a tab + Then the tab appears at the end of the run diff --git a/features/steps/text.py b/features/steps/text.py index 0350ed75f..67e0c5c49 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -86,6 +86,11 @@ def when_I_add_a_run_specifying_the_character_style_Emphasis(context): context.run = context.paragraph.add_run(test_text, 'Emphasis') +@when('I add a tab') +def when_I_add_a_tab(context): + context.run.add_tab() + + @when('I assign {value_str} to its {bool_prop_name} property') def when_assign_true_to_bool_run_prop(context, value_str, bool_prop_name): value = {'True': True, 'False': False, 'None': None}[value_str] @@ -195,3 +200,10 @@ def then_the_style_of_the_run_is_char_style(context, char_style): 'None': None, 'Emphasis': 'Emphasis', 'Strong': 'Strong' }[char_style] assert context.run.style == expected_value + + +@then('the tab appears at the end of the run') +def then_the_tab_appears_at_the_end_of_the_run(context): + r = context.run._r + tab = r.find(qn('w:tab')) + assert tab is not None From 0fc52d4d64c04c287a4fce3fad2e1b2ca858221b Mon Sep 17 00:00:00 2001 From: Frank Battaglia Date: Wed, 25 Jun 2014 01:23:47 -0700 Subject: [PATCH 127/809] run: add Run.add_tab() --- docx/oxml/text.py | 1 + docx/text.py | 7 +++++++ features/run-add-content.feature | 1 - tests/oxml/unitdata/text.py | 13 +++++++++++++ tests/test_text.py | 16 +++++++++++++--- 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 777896513..ef547e58d 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -125,6 +125,7 @@ class CT_R(BaseOxmlElement): rPr = ZeroOrOne('w:rPr') t = ZeroOrMore('w:t') br = ZeroOrMore('w:br') + tab = ZeroOrMore('w:tab') drawing = ZeroOrMore('w:drawing') def _insert_rPr(self, rPr): diff --git a/docx/text.py b/docx/text.py index 59630cc32..56a26fbb9 100644 --- a/docx/text.py +++ b/docx/text.py @@ -151,6 +151,13 @@ def add_break(self, break_type=WD_BREAK.LINE): if clear is not None: br.clear = clear + def add_tab(self): + """ + Add a ```` element at the end of the run, which Word + interprets as a tab character. + """ + self._r._add_tab() + def add_text(self, text): """ Add a text element to this run. diff --git a/features/run-add-content.feature b/features/run-add-content.feature index 04f1eb1ff..a99d11b33 100644 --- a/features/run-add-content.feature +++ b/features/run-add-content.feature @@ -4,7 +4,6 @@ Feature: Add content to a run I need a way to add each of the run content elements to a run - @wip Scenario: Add a tab Given a run When I add a tab diff --git a/tests/oxml/unitdata/text.py b/tests/oxml/unitdata/text.py index 9d3d9eb60..a24a0a897 100644 --- a/tests/oxml/unitdata/text.py +++ b/tests/oxml/unitdata/text.py @@ -14,6 +14,15 @@ class CT_BrBuilder(BaseBuilder): __attrs__ = ('w:type', 'w:clear') +class CT_EmptyBuilder(BaseBuilder): + __nspfxs__ = ('w',) + __attrs__ = () + + def __init__(self, tag): + self.__tag__ = tag + super(CT_EmptyBuilder, self).__init__() + + class CT_PBuilder(BaseBuilder): __tag__ = 'w:p' __nspfxs__ = ('w',) @@ -110,6 +119,10 @@ def a_strike(): return CT_OnOffBuilder('w:strike') +def a_tab(): + return CT_EmptyBuilder('w:tab') + + def a_vanish(): return CT_OnOffBuilder('w:vanish') diff --git a/tests/test_text.py b/tests/test_text.py index ab22ef11a..4f7ffdcec 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -19,9 +19,9 @@ from .oxml.parts.unitdata.document import a_body from .oxml.unitdata.text import ( a_b, a_bCs, a_br, a_caps, a_cs, a_dstrike, a_p, a_pPr, a_pStyle, - a_shadow, a_smallCaps, a_snapToGrid, a_specVanish, a_strike, a_t, a_u, - a_vanish, a_webHidden, an_emboss, an_i, an_iCs, an_imprint, an_oMath, - a_noProof, an_outline, an_r, an_rPr, an_rStyle, an_rtl + a_shadow, a_smallCaps, a_snapToGrid, a_specVanish, a_strike, a_t, a_tab, + a_u, a_vanish, a_webHidden, an_emboss, an_i, an_iCs, an_imprint, + an_oMath, a_noProof, an_outline, an_r, an_rPr, an_rStyle, an_rtl ) from .unitutil import call, class_mock, instance_mock, Mock @@ -207,6 +207,11 @@ def it_can_add_a_break(self, add_break_fixture): run.add_break(break_type) assert run._r.xml == expected_xml + def it_can_add_a_tab(self, add_tab_fixture): + run, expected_xml = add_tab_fixture + run.add_tab() + assert run._r.xml == expected_xml + def it_knows_the_text_it_contains(self, text_prop_fixture): run, expected_text = text_prop_fixture assert run.text == expected_text @@ -241,6 +246,11 @@ def add_break_fixture(self, request, run): expected_xml = an_r().with_nsdecls().with_child(br_bldr).xml() return run, break_type, expected_xml + @pytest.fixture + def add_tab_fixture(self, run): + expected_xml = an_r().with_nsdecls().with_child(a_tab()).xml() + return run, expected_xml + @pytest.fixture(params=['foobar', ' foo bar', 'bar foo ']) def add_text_fixture(self, request, run, Text_): text_str = request.param From e3c2e2ba1a45ae4aafbb43958ed20ae2b25471f3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 25 Jun 2014 21:50:13 -0700 Subject: [PATCH 128/809] acpt: add scenarios for Run.text getter and setter Both handling text containing embedded \n, \t, and/or \r characters. --- features/run-access-content.feature | 10 ++++++++ features/run-add-content.feature | 7 ++++++ features/steps/text.py | 36 ++++++++++++++++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 features/run-access-content.feature diff --git a/features/run-access-content.feature b/features/run-access-content.feature new file mode 100644 index 000000000..3a05bf9db --- /dev/null +++ b/features/run-access-content.feature @@ -0,0 +1,10 @@ +Feature: Access run content + In order to discover or locate existing inline content + As a developer using python-docx + I need ways to access the run content + + + @wip + Scenario: Get run content as Python text + Given a run having mixed text content + Then the text of the run represents the textual run content diff --git a/features/run-add-content.feature b/features/run-add-content.feature index a99d11b33..0e308ceb5 100644 --- a/features/run-add-content.feature +++ b/features/run-add-content.feature @@ -8,3 +8,10 @@ Feature: Add content to a run Given a run When I add a tab Then the tab appears at the end of the run + + + @wip + Scenario: Assign mixed text to text property + Given a run + When I assign mixed text to the text property + Then the text of the run represents the textual run content diff --git a/features/steps/text.py b/features/steps/text.py index 67e0c5c49..8097f3780 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -10,7 +10,9 @@ from docx import Document from docx.enum.text import WD_BREAK, WD_UNDERLINE -from docx.oxml.ns import qn +from docx.oxml import parse_xml +from docx.oxml.ns import nsdecls, qn +from docx.text import Run from helpers import test_docx, test_text @@ -38,6 +40,26 @@ def given_a_run_having_known_text_and_formatting(context): context.run = run +@given('a run having mixed text content') +def given_a_run_having_mixed_text_content(context): + """ + Mixed here meaning it contains ````, ````, etc. elements. + """ + r_xml = """\ + + abc + + def + + ghi + + + jkl + """ % nsdecls('w') + r = parse_xml(r_xml) + context.run = Run(r) + + @given('a run having {underline_type} underline') def given_a_run_having_underline_type(context, underline_type): run_idx = { @@ -91,6 +113,11 @@ def when_I_add_a_tab(context): context.run.add_tab() +@when('I assign mixed text to the text property') +def when_I_assign_mixed_text_to_the_text_property(context): + context.run.text = 'abc\tdef\nghi\rjkl' + + @when('I assign {value_str} to its {bool_prop_name} property') def when_assign_true_to_bool_run_prop(context, value_str, bool_prop_name): value = {'True': True, 'False': False, 'None': None}[value_str] @@ -207,3 +234,10 @@ def then_the_tab_appears_at_the_end_of_the_run(context): r = context.run._r tab = r.find(qn('w:tab')) assert tab is not None + + +@then('the text of the run represents the textual run content') +def then_the_text_of_the_run_represents_the_textual_run_content(context): + assert context.run.text == 'abc\tdef\nghi\njkl', ( + 'got \'%s\'' % context.run.text + ) From 7beb12487b1526f6bb74178be492b377fc02cc04 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 25 Jun 2014 22:14:22 -0700 Subject: [PATCH 129/809] run: add Run.text getter Upgraded to handle more of the run content elements. --- docx/oxml/text.py | 18 ++++++++++++ docx/text.py | 30 +++++++++++++------ tests/oxml/unitdata/text.py | 4 +++ tests/test_text.py | 57 ++++++++++++++++++++++++------------- 4 files changed, 81 insertions(+), 28 deletions(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index ef547e58d..6ddbb0bb4 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -125,6 +125,7 @@ class CT_R(BaseOxmlElement): rPr = ZeroOrOne('w:rPr') t = ZeroOrMore('w:t') br = ZeroOrMore('w:br') + cr = ZeroOrMore('w:cr') tab = ZeroOrMore('w:tab') drawing = ZeroOrMore('w:drawing') @@ -178,6 +179,23 @@ def style(self, style): rPr = self.get_or_add_rPr() rPr.style = style + @property + def text(self): + """ + A string representing the textual content of this run, with content + child elements like ```` translated to their Python + equivalent. + """ + text = '' + for child in self: + if child.tag == qn('w:t'): + text += child.text + elif child.tag == qn('w:tab'): + text += '\t' + elif child.tag in (qn('w:br'), qn('w:cr')): + text += '\n' + return text + @property def underline(self): """ diff --git a/docx/text.py b/docx/text.py index 56a26fbb9..69b3d74b2 100644 --- a/docx/text.py +++ b/docx/text.py @@ -160,7 +160,10 @@ def add_tab(self): def add_text(self, text): """ - Add a text element to this run. + Returns a newly appended |Text| object (corresponding to a new + ```` child element) to the run, containing *text*. Compare with + the possibly more friendly approach of assigning text to the + :attr:`Run.text` property. """ t = self._r.add_t(text) return Text(t) @@ -351,13 +354,22 @@ def style(self, char_style): @property def text(self): """ - A string formed by concatenating all the elements present in - this run. + String formed by concatenating the text equivalent of each run + content child element into a Python string. Each ```` element + adds the text characters it contains. A ```` element adds + a ``\\t`` character. A ```` or ```` element each add + a ``\\n`` character. Note that a ```` element can indicate + a page break or column break as well as a line break. All ```` + elements translate to a single ``\\n`` character regardless of their + type. All other content child elements, such as ````, are + ignored. + + Assigning text to this property has the reverse effect, translating + each ``\\t`` character to a ```` element and each ``\\n`` or + ``\\r`` character to a ```` element. Any existing run content + is replaced. Run formatting is preserved. """ - text = '' - for t in self._r.t_lst: - text += t.text - return text + return self._r.text @property def underline(self): @@ -369,8 +381,8 @@ def underline(self): property removes any directly-applied underline value. A value of |False| indicates a directly-applied setting of no underline, overriding any inherited value. A value of |True| indicates single - underline. The values from ``WD_UNDERLINE`` are used to specify other - outline styles such as double, wavy, and dotted. + underline. The values from :ref:`WdUnderline` are used to specify + other outline styles such as double, wavy, and dotted. """ return self._r.underline diff --git a/tests/oxml/unitdata/text.py b/tests/oxml/unitdata/text.py index a24a0a897..95d78089a 100644 --- a/tests/oxml/unitdata/text.py +++ b/tests/oxml/unitdata/text.py @@ -87,6 +87,10 @@ def a_caps(): return CT_OnOffBuilder('w:caps') +def a_cr(): + return CT_EmptyBuilder('w:cr') + + def a_cs(): return CT_OnOffBuilder('w:cs') diff --git a/tests/test_text.py b/tests/test_text.py index 4f7ffdcec..a34475abd 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -18,7 +18,7 @@ from .oxml.parts.unitdata.document import a_body from .oxml.unitdata.text import ( - a_b, a_bCs, a_br, a_caps, a_cs, a_dstrike, a_p, a_pPr, a_pStyle, + a_b, a_bCs, a_br, a_caps, a_cr, a_cs, a_dstrike, a_p, a_pPr, a_pStyle, a_shadow, a_smallCaps, a_snapToGrid, a_specVanish, a_strike, a_t, a_tab, a_u, a_vanish, a_webHidden, an_emboss, an_i, an_iCs, an_imprint, an_oMath, a_noProof, an_outline, an_r, an_rPr, an_rStyle, an_rtl @@ -212,10 +212,6 @@ def it_can_add_a_tab(self, add_tab_fixture): run.add_tab() assert run._r.xml == expected_xml - def it_knows_the_text_it_contains(self, text_prop_fixture): - run, expected_text = text_prop_fixture - assert run.text == expected_text - def it_can_remove_its_content_while_preserving_formatting( self, clear_fixture): run, expected_xml = clear_fixture @@ -223,6 +219,10 @@ def it_can_remove_its_content_while_preserving_formatting( assert run._r.xml == expected_xml assert _run is run + def it_knows_the_text_it_contains(self, text_get_fixture): + run, expected_text = text_get_fixture + assert run.text == expected_text + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -421,15 +421,17 @@ def style_set_fixture(self, request): expected_xml = self.r_bldr_with_style(after_style).xml() return run, after_style, expected_xml - @pytest.fixture - def text_prop_fixture(self, Text_): - r = ( - an_r().with_nsdecls().with_child( - a_t().with_text('foo')).with_child( - a_t().with_text('bar')) - ).element - run = Run(r) - return run, 'foobar' + @pytest.fixture(params=[ + (('',), ''), + (('xfoobar',), 'foobar'), + (('bpage', 'xabc', 'xdef', 't'), '\nabcdef\t'), + (('xabc', 't', 'xdef', 'n'), 'abc\tdef\n'), + ]) + def text_get_fixture(self, request): + content_children, expected_text = request.param + r_bldr = self.r_content_bldr(content_children) + run = Run(r_bldr.element) + return run, expected_text @pytest.fixture(params=[ (None, None), @@ -471,11 +473,6 @@ def underline_set_fixture(self, request): # fixture components --------------------------------------------- - @pytest.fixture - def run(self): - r = an_r().with_nsdecls().element - return Run(r) - def r_bldr_with_style(self, style): rPr_bldr = an_rPr() if style is not None: @@ -490,6 +487,28 @@ def r_bldr_with_underline(self, underline_type): r_bldr = an_r().with_nsdecls().with_child(rPr_bldr) return r_bldr + def r_content_bldr(self, elm_codes): + """ + Return a ```` builder having child elements indicated by + *elm_codes*. + """ + r_bldr = an_r().with_nsdecls() + for e in elm_codes: + if e.startswith('x'): + r_bldr.with_child(a_t().with_text(e[1:])) + elif e == 't': + r_bldr.with_child(a_tab()) + elif e.startswith('b'): + r_bldr.with_child(a_br().with_type(e[1:])) + elif e == 'n': + r_bldr.with_child(a_cr()) + return r_bldr + + @pytest.fixture + def run(self): + r = an_r().with_nsdecls().element + return Run(r) + @pytest.fixture def Text_(self, request): return class_mock(request, 'docx.text.Text') From 6a85cabfef8021e99c0244e84f1cd5dbf32bbec9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 25 Jun 2014 22:37:34 -0700 Subject: [PATCH 130/809] run: add Run.text setter --- docx/oxml/text.py | 61 +++++++++++++++++++++++++++++ docx/text.py | 4 ++ features/run-access-content.feature | 1 - features/run-add-content.feature | 1 - tests/test_text.py | 20 ++++++++++ 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 6ddbb0bb4..f9a4e5fc1 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -196,6 +196,11 @@ def text(self): text += '\n' return text + @text.setter + def text(self, text): + self.clear_content() + _RunContentAppender.append_to_run_from_text(self, text) + @property def underline(self): """ @@ -319,3 +324,59 @@ def val(self, value): val = WD_UNDERLINE.to_xml(value) self.set(qn('w:val'), val) + + +class _RunContentAppender(object): + """ + Service object that knows how to translate a Python string into run + content elements appended to a specified ```` element. Contiguous + sequences of regular characters are appended in a single ```` + element. Each tab character ('\t') causes a ```` element to be + 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 = [] + + @classmethod + def append_to_run_from_text(cls, r, text): + """ + Create a "one-shot" ``_RunContentAppender`` instance and use it to + append the run content elements corresponding to *text* to the + ```` element *r*. + """ + appender = cls(r) + appender.add_text(text) + + def add_text(self, text): + """ + Append the run content elements corresponding to *text* to the + ```` element of this instance. + """ + for char in text: + self.add_char(char) + self.flush() + + def add_char(self, char): + """ + Process the next character of input through the translation finite + state maching (FSM). There are two possible states, buffer pending + and not pending, but those are hidden behind the ``.flush()`` method + which must be called at the end of text to ensure any pending + ```` element is written. + """ + if char == '\t': + self.flush() + self._r.add_tab() + elif char in '\r\n': + self.flush() + self._r.add_cr() + else: + self._bfr.append(char) + + def flush(self): + text = ''.join(self._bfr) + if text: + self._r.add_t(text) + del self._bfr[:] diff --git a/docx/text.py b/docx/text.py index 69b3d74b2..97a4ceb1a 100644 --- a/docx/text.py +++ b/docx/text.py @@ -371,6 +371,10 @@ def text(self): """ return self._r.text + @text.setter + def text(self, text): + self._r.text = text + @property def underline(self): """ diff --git a/features/run-access-content.feature b/features/run-access-content.feature index 3a05bf9db..ad30f6feb 100644 --- a/features/run-access-content.feature +++ b/features/run-access-content.feature @@ -4,7 +4,6 @@ Feature: Access run content I need ways to access the run content - @wip Scenario: Get run content as Python text Given a run having mixed text content Then the text of the run represents the textual run content diff --git a/features/run-add-content.feature b/features/run-add-content.feature index 0e308ceb5..4a3cb78a0 100644 --- a/features/run-add-content.feature +++ b/features/run-add-content.feature @@ -10,7 +10,6 @@ Feature: Add content to a run Then the tab appears at the end of the run - @wip Scenario: Assign mixed text to text property Given a run When I assign mixed text to the text property diff --git a/tests/test_text.py b/tests/test_text.py index a34475abd..c931e9a8b 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -223,6 +223,11 @@ def it_knows_the_text_it_contains(self, text_get_fixture): run, expected_text = text_get_fixture assert run.text == expected_text + def it_can_replace_the_text_it_contains(self, text_set_fixture): + run, text, expected_xml = text_set_fixture + run.text = text + assert run._r.xml == expected_xml + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -433,6 +438,21 @@ def text_get_fixture(self, request): run = Run(r_bldr.element) return run, expected_text + @pytest.fixture(params=[ + ('abc', ('xabc',)), + ('abc\tdef', ('xabc', 't', 'xdef')), + ('abc\rdef', ('xabc', 'n', 'xdef')), + ]) + def text_set_fixture(self, request): + text, expected_elm_codes = request.param + # starting run contains text, so can tell if it doesn't get replaced + r_bldr = self.r_content_bldr(('xfoobar')) + run = Run(r_bldr.element) + # expected_xml ----------------- + r_bldr = self.r_content_bldr(expected_elm_codes) + expected_xml = r_bldr.xml() + return run, text, expected_xml + @pytest.fixture(params=[ (None, None), ('single', True), From c89674dc44b1565efd3d03041119f02df2ab18cc Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 25 Jun 2014 23:49:09 -0700 Subject: [PATCH 131/809] text: support mixed text in existing methods By using run.text = text instead of run.add_text() in text adding methods, the parsing logic to interpret newlines and tabs is invoked automatically. --- docs/dev/analysis/schema/ct_p.rst | 46 +++++++++++++++---------------- docx/api.py | 6 +++- docx/table.py | 2 +- docx/text.py | 8 ++++-- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/docs/dev/analysis/schema/ct_p.rst b/docs/dev/analysis/schema/ct_p.rst index da0c418c0..d1b158a38 100644 --- a/docs/dev/analysis/schema/ct_p.rst +++ b/docs/dev/analysis/schema/ct_p.rst @@ -34,16 +34,16 @@ Schema excerpt - - - - - - - - - - + + + + + + + + + + @@ -88,14 +88,17 @@ Schema excerpt - + + + + + + - - - - + + @@ -103,23 +106,20 @@ Schema excerpt + + + - - - - - - - - + + diff --git a/docx/api.py b/docx/api.py index 345302558..d1f11ed06 100644 --- a/docx/api.py +++ b/docx/api.py @@ -63,7 +63,11 @@ def add_page_break(self): 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*. + with *text* and having paragraph style *style*. *text* can contain + tab (``\\t``) characters, which are converted to the appropriate XML + form for a tab. *text* can also include newline (``\\n``) or carriage + return (``\\r``) characters, each of which is converted to a line + break. """ paragraph = self._document_part.add_paragraph() if text: diff --git a/docx/table.py b/docx/table.py index c9e3fcf6f..2870583a2 100644 --- a/docx/table.py +++ b/docx/table.py @@ -112,7 +112,7 @@ def text(self, text): tc.clear_content() p = tc.add_p() r = p.add_r() - r.add_t(text) + r.text = text class _Column(object): diff --git a/docx/text.py b/docx/text.py index 97a4ceb1a..69d31461d 100644 --- a/docx/text.py +++ b/docx/text.py @@ -61,12 +61,16 @@ def __init__(self, p): def add_run(self, text=None, style=None): """ Append a run to this paragraph containing *text* and having character - style identified by style ID *style*. + style identified by style ID *style*. *text* can contain tab + (``\\t``) characters, which are converted to the appropriate XML form + for a tab. *text* can also include newline (``\\n``) or carriage + return (``\\r``) characters, each of which is converted to a line + break. """ r = self._p.add_r() run = Run(r) if text: - run.add_text(text) + run.text = text if style: run.style = style return run From 4ecf198592a286acfba7bb9a8f3f81da98f999ec Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 26 Jun 2014 16:23:51 -0700 Subject: [PATCH 132/809] acpt: add par-clear-paragraph.feature With scenario for Paragraph.clear() --- features/par-clear-paragraph.feature | 12 ++++++++++ features/steps/paragraph.py | 35 +++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 features/par-clear-paragraph.feature diff --git a/features/par-clear-paragraph.feature b/features/par-clear-paragraph.feature new file mode 100644 index 000000000..6ab44ab52 --- /dev/null +++ b/features/par-clear-paragraph.feature @@ -0,0 +1,12 @@ +Feature: Clear paragraph content + In order to change paragraph content while retaining its formatting + As a developer using python-docx + I need a way to remove the content of a paragraph + + + @wip + Scenario: Clear paragraph content + Given a paragraph with content and formatting + When I clear the paragraph content + Then the paragraph has no content + But the paragraph formatting is preserved diff --git a/features/steps/paragraph.py b/features/steps/paragraph.py index 1646245ca..32c00d31f 100644 --- a/features/steps/paragraph.py +++ b/features/steps/paragraph.py @@ -7,6 +7,9 @@ from behave import given, then, when from docx import Document +from docx.oxml import parse_xml +from docx.oxml.ns import nsdecls +from docx.text import Paragraph from helpers import saved_docx_path, test_text @@ -25,6 +28,21 @@ def given_a_document_containing_three_paragraphs(context): context.document = document +@given('a paragraph with content and formatting') +def given_a_paragraph_with_content_and_formatting(context): + p_xml = """\ + + + + + + foobar + + """ % (nsdecls('w'), TEST_STYLE) + p = parse_xml(p_xml) + context.paragraph = Paragraph(p) + + # when ==================================================== @when('I add a run to the paragraph') @@ -33,10 +51,15 @@ def when_add_new_run_to_paragraph(context): @when('I add text to the run') -def when_add_new_text_to_run(context): +def when_I_add_text_to_the_run(context): context.r.add_text(test_text) +@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') def when_I_insert_a_paragraph_above_the_second_paragraph(context): paragraph = context.document.paragraphs[1] @@ -65,6 +88,16 @@ def then_document_contains_text_I_added(context): assert r.text == test_text +@then('the paragraph formatting is preserved') +def then_the_paragraph_formatting_is_preserved(context): + assert context.paragraph.style == TEST_STYLE + + +@then('the paragraph has no content') +def then_the_paragraph_has_no_content(context): + assert context.paragraph.text == '' + + @then('the paragraph has the style I set') def then_the_paragraph_has_the_style_I_set(context): paragraph = Document(saved_docx_path).paragraphs[-1] From 70f0605fea2aaaa3b8ab43af0026c1554999dbfb Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 26 Jun 2014 16:33:47 -0700 Subject: [PATCH 133/809] para: add Paragraph.clear() --- docx/oxml/text.py | 9 +++++++++ docx/text.py | 8 ++++++++ features/par-clear-paragraph.feature | 1 - tests/test_text.py | 25 +++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index f9a4e5fc1..f47b8ecd5 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -40,6 +40,15 @@ def add_p_before(self): self.addprevious(new_p) return new_p + def clear_content(self): + """ + Remove all child elements, except the ```` element if present. + """ + for child in self[:]: + if child.tag == qn('w:pPr'): + continue + self.remove(child) + def set_sectPr(self, sectPr): """ Unconditionally replace or add *sectPr* as a grandchild in the diff --git a/docx/text.py b/docx/text.py index 69d31461d..5b3545dca 100644 --- a/docx/text.py +++ b/docx/text.py @@ -75,6 +75,14 @@ def add_run(self, text=None, style=None): run.style = style return run + def clear(self): + """ + Return this same paragraph after removing all its content. + Paragraph-level formatting, such as style, is preserved. + """ + self._p.clear_content() + return self + def insert_paragraph_before(self, text=None, style=None): """ Return a newly created paragraph, inserted directly before this diff --git a/features/par-clear-paragraph.feature b/features/par-clear-paragraph.feature index 6ab44ab52..a2672803e 100644 --- a/features/par-clear-paragraph.feature +++ b/features/par-clear-paragraph.feature @@ -4,7 +4,6 @@ Feature: Clear paragraph content I need a way to remove the content of a paragraph - @wip Scenario: Clear paragraph content Given a paragraph with content and formatting When I clear the paragraph content diff --git a/tests/test_text.py b/tests/test_text.py index c931e9a8b..886c1b734 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -73,6 +73,13 @@ def it_can_insert_a_paragraph_before_itself(self, insert_before_fixture): assert new_paragraph.style == style assert body.xml == expected_xml + 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 + assert _paragraph is paragraph + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -90,6 +97,24 @@ def add_run_fixture(self, request, paragraph): expected_xml = a_p().with_nsdecls().with_child(r_bldr).xml() return paragraph, text, style, expected_xml + @pytest.fixture + def clear_fixture(self, request): + """ + After XML should be before XML with content removed. So snapshot XML + after adding formatting but before adding content to get after XML. + """ + style, text = ('Heading1', 'foo\tbar') + p = OxmlElement('w:p') + # expected_xml ----------------- + if style is not None: + p.style = style + expected_xml = p.xml + # paragraph -------------------- + paragraph = Paragraph(p) + if text is not None: + paragraph.add_run(text) + return paragraph, expected_xml + @pytest.fixture def insert_before_fixture(self): text, style = 'foobar', 'Heading1' From 70b580e17607b48398ae8f3c8023804e2a8bc634 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 26 Jun 2014 21:53:40 -0700 Subject: [PATCH 134/809] para: reposition a couple test fixtures No code changes. --- features/steps/paragraph.py | 7 +------ features/steps/text.py | 5 +++++ tests/test_text.py | 42 ++++++++++++++++++------------------- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/features/steps/paragraph.py b/features/steps/paragraph.py index 32c00d31f..35eb0c25d 100644 --- a/features/steps/paragraph.py +++ b/features/steps/paragraph.py @@ -47,12 +47,7 @@ def given_a_paragraph_with_content_and_formatting(context): @when('I add a run to the paragraph') def when_add_new_run_to_paragraph(context): - context.r = context.p.add_run() - - -@when('I add text to the run') -def when_I_add_text_to_the_run(context): - context.r.add_text(test_text) + context.run = context.p.add_run() @when('I clear the paragraph content') diff --git a/features/steps/text.py b/features/steps/text.py index 8097f3780..b1a003042 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -113,6 +113,11 @@ def when_I_add_a_tab(context): context.run.add_tab() +@when('I add text to the run') +def when_I_add_text_to_the_run(context): + context.run.add_text(test_text) + + @when('I assign mixed text to the text property') def when_I_assign_mixed_text_to_the_text_property(context): context.run.text = 'abc\tdef\nghi\rjkl' diff --git a/tests/test_text.py b/tests/test_text.py index 886c1b734..d543d7e01 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -61,9 +61,9 @@ def it_can_set_its_paragraph_style(self): p.style = style assert p_elm.style == expected_setting - def it_knows_the_text_it_contains(self, text_prop_fixture): - p, expected_text = text_prop_fixture - assert p.text == expected_text + def it_knows_the_text_it_contains(self, text_get_fixture): + paragraph, expected_text = text_get_fixture + assert paragraph.text == expected_text def it_can_insert_a_paragraph_before_itself(self, insert_before_fixture): paragraph, text, style, body, expected_xml = insert_before_fixture @@ -135,6 +135,24 @@ def insert_before_fixture(self): ).xml() return paragraph, text, style, body, expected_xml + @pytest.fixture + def runs_fixture(self, p_, Run_, r_, r_2_, runs_): + paragraph = Paragraph(p_) + run_, run_2_ = runs_ + return paragraph, Run_, r_, r_2_, run_, run_2_ + + @pytest.fixture + def text_get_fixture(self): + p = ( + a_p().with_nsdecls().with_child( + an_r().with_child( + a_t().with_text('foo'))).with_child( + an_r().with_child( + a_t().with_text(' de bar'))) + ).element + paragraph = Paragraph(p) + return paragraph, 'foo de bar' + # fixture components --------------------------------------------- @pytest.fixture @@ -167,24 +185,6 @@ def runs_(self, request): run_2_ = instance_mock(request, Run, name='run_2_') return run_, run_2_ - @pytest.fixture - def runs_fixture(self, p_, Run_, r_, r_2_, runs_): - paragraph = Paragraph(p_) - run_, run_2_ = runs_ - return paragraph, Run_, r_, r_2_, run_, run_2_ - - @pytest.fixture - def text_prop_fixture(self): - p = ( - a_p().with_nsdecls().with_child( - an_r().with_child( - a_t().with_text('foo'))).with_child( - an_r().with_child( - a_t().with_text(' de bar'))) - ).element - paragraph = Paragraph(p) - return paragraph, 'foo de bar' - class DescribeRun(object): From 97b2e62cde60abebd7e93097eb066e33bf1dadab Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 26 Jun 2014 21:57:59 -0700 Subject: [PATCH 135/809] acpt: add par-set-text.feature with scenario for Paragraph.text setter --- features/par-set-text.feature | 12 ++++++++++++ features/steps/paragraph.py | 10 ++++++++++ 2 files changed, 22 insertions(+) create mode 100644 features/par-set-text.feature diff --git a/features/par-set-text.feature b/features/par-set-text.feature new file mode 100644 index 000000000..7e74cd6ff --- /dev/null +++ b/features/par-set-text.feature @@ -0,0 +1,12 @@ +Feature: Replace paragraph text + In order to conveniently change the text of a paragraph in place + As a developer using python-docx + I need a writable text property on paragraph + + + @wip + Scenario: Set paragraph text + Given a paragraph with content and formatting + When I set the paragraph text + Then the paragraph has the text I set + And the paragraph formatting is preserved diff --git a/features/steps/paragraph.py b/features/steps/paragraph.py index 35eb0c25d..28f7e7778 100644 --- a/features/steps/paragraph.py +++ b/features/steps/paragraph.py @@ -67,6 +67,11 @@ def when_I_set_the_paragraph_style(context): context.paragraph.style = TEST_STYLE +@when('I set the paragraph text') +def when_I_set_the_paragraph_text(context): + context.paragraph.text = 'bar\tfoo\r' + + # then ===================================================== @then('the document contains four paragraphs') @@ -99,6 +104,11 @@ def then_the_paragraph_has_the_style_I_set(context): assert paragraph.style == TEST_STYLE +@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' + + @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] From 1a8e23220de30d54232a13ec84997c82e2e0cbc6 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 26 Jun 2014 22:01:43 -0700 Subject: [PATCH 136/809] para: add Paragraph.text setter --- docx/text.py | 17 +++++++++++++++-- features/par-set-text.feature | 1 - tests/test_text.py | 12 ++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docx/text.py b/docx/text.py index 5b3545dca..3a98b7985 100644 --- a/docx/text.py +++ b/docx/text.py @@ -121,14 +121,27 @@ def style(self, style): @property def text(self): """ - A string formed by concatenating the text of each run in the - paragraph. + String formed by concatenating the text of each run in the paragraph. + Tabs and line breaks in the XML are mapped to ``\\t`` and ``\\n`` + characters respectively. + + Assigning text to this property causes all existing paragraph content + to be replaced with a single run containing the assigned text. + A ``\\t`` character in the text is mapped to a ```` element + and each ``\\n`` or ``\\r`` character is mapped to a line break. + Paragraph-level formatting, such as style, is preserved. All + run-level formatting, such as bold or italic, is removed. """ text = '' for run in self.runs: text += run.text return text + @text.setter + def text(self, text): + self.clear() + self.add_run(text) + class Run(object): """ diff --git a/features/par-set-text.feature b/features/par-set-text.feature index 7e74cd6ff..1ffb3f973 100644 --- a/features/par-set-text.feature +++ b/features/par-set-text.feature @@ -4,7 +4,6 @@ Feature: Replace paragraph text I need a writable text property on paragraph - @wip Scenario: Set paragraph text Given a paragraph with content and formatting When I set the paragraph text diff --git a/tests/test_text.py b/tests/test_text.py index d543d7e01..49ed7e574 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -65,6 +65,11 @@ def it_knows_the_text_it_contains(self, text_get_fixture): paragraph, expected_text = text_get_fixture assert paragraph.text == expected_text + def it_can_replace_the_text_it_contains(self, text_set_fixture): + paragraph, text, expected_text = text_set_fixture + paragraph.text = text + assert paragraph.text == expected_text + def it_can_insert_a_paragraph_before_itself(self, insert_before_fixture): paragraph, text, style, body, expected_xml = insert_before_fixture new_paragraph = paragraph.insert_paragraph_before(text, style) @@ -153,6 +158,13 @@ def text_get_fixture(self): paragraph = Paragraph(p) return paragraph, 'foo de bar' + @pytest.fixture + def text_set_fixture(self): + p = a_p().with_nsdecls().element + paragraph = Paragraph(p) + paragraph.add_run('barfoo') + return paragraph, 'foo\tbar\rbaz\n', 'foo\tbar\nbaz\n' + # fixture components --------------------------------------------- @pytest.fixture From 013850066fb85df598c046181b65e2e0fd65785c Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 27 Jun 2014 01:09:14 -0700 Subject: [PATCH 137/809] docs: add Paragraph.alignment feature analysis --- docs/dev/analysis/features/par-alignment.rst | 174 +++++++++++++++++++ docs/dev/analysis/index.rst | 1 + 2 files changed, 175 insertions(+) create mode 100644 docs/dev/analysis/features/par-alignment.rst diff --git a/docs/dev/analysis/features/par-alignment.rst b/docs/dev/analysis/features/par-alignment.rst new file mode 100644 index 000000000..48c29cf67 --- /dev/null +++ b/docs/dev/analysis/features/par-alignment.rst @@ -0,0 +1,174 @@ + +Paragraph alignment +=================== + +In Word, each paragraph has an *alignment* attribute that specifies how to +justify the lines of the paragraph when the paragraph is laid out on the +page. Common values are left, right, centered, and justified. + + +Protocol +-------- + +The protocol for getting and setting paragraph alignment is illustrated in +this interactive session:: + + >>> paragraph = body.add_paragraph() + >>> paragraph.alignment + None + >>> paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT + >>> paragraph.alignment + RIGHT (2) + >>> paragraph.alignment = None + >>> paragraph.alignment + None + + +Semantics +--------- + +If the ```` element is not present on a paragraph, the alignment value +for that paragraph is inherited from its style hierarchy. If the element is +present, its value overrides any inherited value. From the API, a value of +|None| on the ``Paragraph.alignment`` property corresponds to no ```` +element being present. If |None| is assigned to ``Paragraph.alignment``, the +```` element is removed. + + +Enumerations +------------ + +WD_ALIGN_PARAGRAPH +~~~~~~~~~~~~~~~~~~ + +`WdParagraphAlignment Enumeration on MSDN`_ + ++--------------+------+----------------+ +| Name | enum | attr | ++==============+======+================+ +| LEFT | 0 | left | ++--------------+------+----------------+ +| CENTER | 1 | center | ++--------------+------+----------------+ +| RIGHT | 2 | right | ++--------------+------+----------------+ +| JUSTIFY | 3 | both | ++--------------+------+----------------+ +| DISTRIBUTE | 4 | distribute | ++--------------+------+----------------+ +| JUSTIFY_MED | 5 | mediumKashida | ++--------------+------+----------------+ +| JUSTIFY_HI | 7 | highKashida | ++--------------+------+----------------+ +| JUSTIFY_LOW | 8 | lowKashida | ++--------------+------+----------------+ +| THAI_JUSTIFY | 9 | thaiDistribute | ++--------------+------+----------------+ + +.. _WdParagraphAlignment Enumeration on MSDN: + http://msdn.microsoft.com/en-us/library/office/ff835817(v=office.15).aspx + + +Specimen XML +------------ + +.. highlight:: xml + +A paragraph with inherited alignment:: + + + + Inherited paragraph alignment. + + + +A right-aligned paragraph:: + + + + + + + Right-aligned paragraph. + + + + +Schema excerpt +-------------- + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index a648304fc..f161da127 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,7 @@ Feature Analysis .. toctree:: :maxdepth: 1 + features/par-alignment features/run-content features/numbering features/underline From 38aeb63e7a09b07042a0558a896fd1d8129adb43 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 27 Jun 2014 01:10:14 -0700 Subject: [PATCH 138/809] enum: add WD_PARAGRAPH_ALIGNMENT enumeration --- docs/api/enum/WdAlignParagraph.rst | 45 +++++++++++++++++++++++ docs/api/enum/index.rst | 1 + docx/enum/text.py | 57 +++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 docs/api/enum/WdAlignParagraph.rst diff --git a/docs/api/enum/WdAlignParagraph.rst b/docs/api/enum/WdAlignParagraph.rst new file mode 100644 index 000000000..82ff6fa25 --- /dev/null +++ b/docs/api/enum/WdAlignParagraph.rst @@ -0,0 +1,45 @@ +.. _WdParagraphAlignment: + +``WD_PARAGRAPH_ALIGNMENT`` +========================== + +alias: **WD_ALIGN_PARAGRAPH** + +Specifies paragraph justification type. + +Example:: + + from docx.enum.text import WD_ALIGN_PARAGRAPH + + paragraph = document.add_paragraph() + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + +---- + +LEFT + Left-aligned + +CENTER + Center-aligned. + +RIGHT + Right-aligned. + +JUSTIFY + Fully justified. + +DISTRIBUTE + Paragraph characters are distributed to fill the entire width of the + paragraph. + +JUSTIFY_MED + Justified with a medium character compression ratio. + +JUSTIFY_HI + Justified with a high character compression ratio. + +JUSTIFY_LOW + Justified with a low character compression ratio. + +THAI_JUSTIFY + Justified according to Thai formatting layout. diff --git a/docs/api/enum/index.rst b/docs/api/enum/index.rst index 6c307272c..576f45856 100644 --- a/docs/api/enum/index.rst +++ b/docs/api/enum/index.rst @@ -8,6 +8,7 @@ can be found here: .. toctree:: :titlesonly: + WdAlignParagraph WdOrientation WdSectionStart WdUnderline diff --git a/docx/enum/text.py b/docx/enum/text.py index a7fd540e8..713597fc6 100644 --- a/docx/enum/text.py +++ b/docx/enum/text.py @@ -6,7 +6,62 @@ from __future__ import absolute_import, print_function, unicode_literals -from .base import XmlEnumeration, XmlMappedEnumMember +from .base import alias, XmlEnumeration, XmlMappedEnumMember + + +@alias('WD_ALIGN_PARAGRAPH') +class WD_PARAGRAPH_ALIGNMENT(XmlEnumeration): + """ + alias: **WD_ALIGN_PARAGRAPH** + + Specifies paragraph justification type. + + Example:: + + from docx.enum.text import WD_ALIGN_PARAGRAPH + + paragraph = document.add_paragraph() + paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER + """ + + __ms_name__ = 'WdParagraphAlignment' + + __url__ = 'http://msdn.microsoft.com/en-us/library/office/ff835817.aspx' + + __members__ = ( + XmlMappedEnumMember( + 'LEFT', 0, 'left', 'Left-aligned' + ), + XmlMappedEnumMember( + 'CENTER', 1, 'center', 'Center-aligned.' + ), + XmlMappedEnumMember( + 'RIGHT', 2, 'right', 'Right-aligned.' + ), + XmlMappedEnumMember( + 'JUSTIFY', 3, 'both', 'Fully justified.' + ), + XmlMappedEnumMember( + 'DISTRIBUTE', 4, 'distribute', 'Paragraph characters are distrib' + 'uted to fill the entire width of the paragraph.' + ), + XmlMappedEnumMember( + 'JUSTIFY_MED', 5, 'mediumKashida', 'Justified with a medium char' + 'acter compression ratio.' + ), + XmlMappedEnumMember( + 'JUSTIFY_HI', 7, 'highKashida', 'Justified with a high character' + ' compression ratio.' + ), + XmlMappedEnumMember( + 'JUSTIFY_LOW', 8, 'lowKashida', 'Justified with a low character ' + 'compression ratio.' + ), + XmlMappedEnumMember( + 'THAI_JUSTIFY', 9, 'thaiDistribute', 'Justified according to Tha' + 'i formatting layout.' + ), + ) class WD_BREAK_TYPE(object): From d7f2bef29181e94b8844e297fd52159b2613b4d6 Mon Sep 17 00:00:00 2001 From: Justin Date: Fri, 27 Jun 2014 01:12:00 -0700 Subject: [PATCH 139/809] acpt: add par-alignment-prop.feature with scenario for read/write Paragraph.alignment property --- features/par-alignment-prop.feature | 17 ++++++++++++ features/steps/paragraph.py | 27 ++++++++++++++++++- features/steps/test_files/par-alignment.docx | Bin 0 -> 15126 bytes 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 features/par-alignment-prop.feature create mode 100644 features/steps/test_files/par-alignment.docx diff --git a/features/par-alignment-prop.feature b/features/par-alignment-prop.feature new file mode 100644 index 000000000..ef20671b4 --- /dev/null +++ b/features/par-alignment-prop.feature @@ -0,0 +1,17 @@ +Feature: Get or set paragraph alignment + In order to specify the justification of a paragraph + As a python-docx developer + I need a read/write alignment property on paragraph objects + + + @wip + Scenario Outline: Get paragraph alignment + Given a paragraph having alignment + Then the paragraph alignment property value is + + Examples: align property values + | align-type | align-value | + | inherited | None | + | left | WD_ALIGN_PARAGRAPH.LEFT | + | center | WD_ALIGN_PARAGRAPH.CENTER | + | right | WD_ALIGN_PARAGRAPH.RIGHT | diff --git a/features/steps/paragraph.py b/features/steps/paragraph.py index 28f7e7778..6f106b862 100644 --- a/features/steps/paragraph.py +++ b/features/steps/paragraph.py @@ -7,11 +7,12 @@ from behave import given, then, when from docx import Document +from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml import parse_xml from docx.oxml.ns import nsdecls from docx.text import Paragraph -from helpers import saved_docx_path, test_text +from helpers import saved_docx_path, test_docx, test_text TEST_STYLE = 'Heading1' @@ -28,6 +29,19 @@ def given_a_document_containing_three_paragraphs(context): context.document = document +@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, + }[align_type] + document = Document(test_docx('par-alignment')) + context.paragraph = document.paragraphs[paragraph_idx] + + @given('a paragraph with content and formatting') def given_a_paragraph_with_content_and_formatting(context): p_xml = """\ @@ -88,6 +102,17 @@ def then_document_contains_text_I_added(context): assert r.text == test_text +@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, + }[align_value] + assert context.paragraph.alignment == expected_value + + @then('the paragraph formatting is preserved') def then_the_paragraph_formatting_is_preserved(context): assert context.paragraph.style == TEST_STYLE diff --git a/features/steps/test_files/par-alignment.docx b/features/steps/test_files/par-alignment.docx new file mode 100644 index 0000000000000000000000000000000000000000..59bbffc13271e9a5a9d233f8fb6935ce47d2c643 GIT binary patch literal 15126 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 Date: Fri, 27 Jun 2014 01:17:54 -0700 Subject: [PATCH 140/809] para: add Paragraph.alignment getter --- docx/oxml/__init__.py | 3 ++- docx/oxml/text.py | 34 ++++++++++++++++++++++++++++++++-- docx/text.py | 11 +++++++++++ tests/oxml/unitdata/text.py | 14 ++++++++++++-- tests/test_text.py | 28 +++++++++++++++++++++++----- 5 files changed, 80 insertions(+), 10 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 07a766477..39e042aa5 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -125,7 +125,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:tr', CT_Row) from docx.oxml.text import ( - CT_Br, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text, CT_Underline + CT_Br, CT_Jc, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text, CT_Underline ) register_element_cls('w:b', CT_OnOff) register_element_cls('w:bCs', CT_OnOff) @@ -137,6 +137,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): 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:jc', CT_Jc) register_element_cls('w:noProof', CT_OnOff) register_element_cls('w:oMath', CT_OnOff) register_element_cls('w:outline', CT_OnOff) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index f47b8ecd5..ff52c1d63 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -5,11 +5,12 @@ (CT_R). """ -from ..enum.text import WD_UNDERLINE +from ..enum.text import WD_ALIGN_PARAGRAPH, WD_UNDERLINE from .ns import qn from .simpletypes import ST_BrClear, ST_BrType from .xmlchemy import ( - BaseOxmlElement, OptionalAttribute, OxmlElement, ZeroOrMore, ZeroOrOne + BaseOxmlElement, OptionalAttribute, OxmlElement, RequiredAttribute, + ZeroOrMore, ZeroOrOne ) @@ -21,6 +22,13 @@ class CT_Br(BaseOxmlElement): clear = OptionalAttribute('w:clear', ST_BrClear) +class CT_Jc(BaseOxmlElement): + """ + ```` element, specifying paragraph justification. + """ + val = RequiredAttribute('w:val', WD_ALIGN_PARAGRAPH) + + class CT_P(BaseOxmlElement): """ ```` element, containing the properties and text for a paragraph. @@ -40,6 +48,17 @@ def add_p_before(self): self.addprevious(new_p) return new_p + @property + def alignment(self): + """ + The value of the ```` grandchild element or |None| if not + present. + """ + pPr = self.pPr + if pPr is None: + return None + return pPr.alignment + def clear_content(self): """ Remove all child elements, except the ```` element if present. @@ -96,12 +115,23 @@ class CT_PPr(BaseOxmlElement): ) pStyle = ZeroOrOne('w:pStyle') numPr = ZeroOrOne('w:numPr', successors=__child_sequence__[7:]) + jc = ZeroOrOne('w:jc', successors=__child_sequence__[27:]) sectPr = ZeroOrOne('w:sectPr', successors=('w:pPrChange',)) def _insert_pStyle(self, pStyle): self.insert(0, pStyle) return pStyle + @property + def alignment(self): + """ + The value of the ```` child element or |None| if not present. + """ + jc = self.jc + if jc is None: + return None + return jc.val + @property def style(self): """ diff --git a/docx/text.py b/docx/text.py index 3a98b7985..5dbcda45a 100644 --- a/docx/text.py +++ b/docx/text.py @@ -75,6 +75,17 @@ def add_run(self, text=None, style=None): run.style = style return run + @property + def alignment(self): + """ + A member of the :ref:`WdParagraphAlignment` enumeration specifying + the justification setting for this paragraph. A value of |None| + indicates the paragraph has no directly-applied alignment value and + will inherit its alignment value from its style hierarchy. Assigning + |None| to this property removes any directly-applied alignment value. + """ + return self._p.alignment + def clear(self): """ Return this same paragraph after removing all its content. diff --git a/tests/oxml/unitdata/text.py b/tests/oxml/unitdata/text.py index 95d78089a..361296147 100644 --- a/tests/oxml/unitdata/text.py +++ b/tests/oxml/unitdata/text.py @@ -23,6 +23,12 @@ def __init__(self, tag): super(CT_EmptyBuilder, self).__init__() +class CT_JcBuilder(BaseBuilder): + __tag__ = 'w:jc' + __nspfxs__ = ('w',) + __attrs__ = ('w:val',) + + class CT_PBuilder(BaseBuilder): __tag__ = 'w:p' __nspfxs__ = ('w',) @@ -63,7 +69,7 @@ def with_space(self, value): return self -class CT_Underline(BaseBuilder): +class CT_UnderlineBuilder(BaseBuilder): __tag__ = 'w:u' __nspfxs__ = ('w',) __attrs__ = ( @@ -99,6 +105,10 @@ def a_dstrike(): return CT_OnOffBuilder('w:dstrike') +def a_jc(): + return CT_JcBuilder() + + def a_noProof(): return CT_OnOffBuilder('w:noProof') @@ -156,7 +166,7 @@ def a_t(): def a_u(): - return CT_Underline() + return CT_UnderlineBuilder() def an_emboss(): diff --git a/tests/test_text.py b/tests/test_text.py index 49ed7e574..e1db1f97a 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -8,7 +8,7 @@ absolute_import, division, print_function, unicode_literals ) -from docx.enum.text import WD_BREAK, WD_UNDERLINE +from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_UNDERLINE from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.oxml.text import CT_P, CT_R @@ -18,10 +18,11 @@ from .oxml.parts.unitdata.document import a_body from .oxml.unitdata.text import ( - a_b, a_bCs, a_br, a_caps, a_cr, a_cs, a_dstrike, a_p, a_pPr, a_pStyle, - a_shadow, a_smallCaps, a_snapToGrid, a_specVanish, a_strike, a_t, a_tab, - a_u, a_vanish, a_webHidden, an_emboss, an_i, an_iCs, an_imprint, - an_oMath, a_noProof, an_outline, an_r, an_rPr, an_rStyle, an_rtl + a_b, a_bCs, a_br, a_caps, a_cr, a_cs, a_dstrike, a_jc, a_p, a_pPr, + a_pStyle, a_shadow, a_smallCaps, a_snapToGrid, a_specVanish, a_strike, + a_t, a_tab, a_u, a_vanish, a_webHidden, an_emboss, an_i, an_iCs, + an_imprint, an_oMath, a_noProof, an_outline, an_r, an_rPr, an_rStyle, + an_rtl ) from .unitutil import call, class_mock, instance_mock, Mock @@ -41,6 +42,10 @@ def it_can_add_a_run_to_itself(self, add_run_fixture): assert isinstance(run, Run) assert run._r is paragraph._p.r_lst[0] + def it_knows_its_alignment_value(self, alignment_get_fixture): + paragraph, expected_value = alignment_get_fixture + assert paragraph.alignment == expected_value + def it_knows_its_paragraph_style(self): cases = ( (Mock(name='p_elm', style='foobar'), 'foobar'), @@ -102,6 +107,19 @@ def add_run_fixture(self, request, paragraph): expected_xml = a_p().with_nsdecls().with_child(r_bldr).xml() return paragraph, text, style, expected_xml + @pytest.fixture(params=[ + ('center', WD_ALIGN_PARAGRAPH.CENTER), + (None, None), + ]) + def alignment_get_fixture(self, request): + jc_val, expected_alignment_value = request.param + p_bldr = a_p().with_nsdecls() + if jc_val is not None: + p_bldr.with_child(a_pPr().with_child(a_jc().with_val(jc_val))) + p = p_bldr.element + paragraph = Paragraph(p) + return paragraph, expected_alignment_value + @pytest.fixture def clear_fixture(self, request): """ From 25abe5b00f501b68bbaf03b0ce0dcb4097f881bf Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 27 Jun 2014 01:21:07 -0700 Subject: [PATCH 141/809] para: add Paragraph.alignment setter --- docx/oxml/text.py | 13 +++++++++++++ docx/text.py | 4 ++++ features/par-alignment-prop.feature | 1 - tests/test_text.py | 30 +++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index ff52c1d63..88264dad2 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -59,6 +59,11 @@ def alignment(self): return None return pPr.alignment + @alignment.setter + def alignment(self, value): + pPr = self.get_or_add_pPr() + pPr.alignment = value + def clear_content(self): """ Remove all child elements, except the ```` element if present. @@ -132,6 +137,14 @@ def alignment(self): return None return jc.val + @alignment.setter + def alignment(self, value): + if value is None: + self._remove_jc() + return + jc = self.get_or_add_jc() + jc.val = value + @property def style(self): """ diff --git a/docx/text.py b/docx/text.py index 5dbcda45a..69adf1c21 100644 --- a/docx/text.py +++ b/docx/text.py @@ -86,6 +86,10 @@ def alignment(self): """ return self._p.alignment + @alignment.setter + def alignment(self, value): + self._p.alignment = value + def clear(self): """ Return this same paragraph after removing all its content. diff --git a/features/par-alignment-prop.feature b/features/par-alignment-prop.feature index ef20671b4..190fbba35 100644 --- a/features/par-alignment-prop.feature +++ b/features/par-alignment-prop.feature @@ -4,7 +4,6 @@ Feature: Get or set paragraph alignment I need a read/write alignment property on paragraph objects - @wip Scenario Outline: Get paragraph alignment Given a paragraph having alignment Then the paragraph alignment property value is diff --git a/tests/test_text.py b/tests/test_text.py index e1db1f97a..009e113a7 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -46,6 +46,11 @@ def it_knows_its_alignment_value(self, alignment_get_fixture): paragraph, expected_value = alignment_get_fixture assert paragraph.alignment == expected_value + def it_can_change_its_alignment_value(self, alignment_set_fixture): + paragraph, value, expected_xml = alignment_set_fixture + paragraph.alignment = value + assert paragraph._p.xml == expected_xml + def it_knows_its_paragraph_style(self): cases = ( (Mock(name='p_elm', style='foobar'), 'foobar'), @@ -120,6 +125,31 @@ def alignment_get_fixture(self, request): paragraph = Paragraph(p) return paragraph, expected_alignment_value + @pytest.fixture(params=[ + ('left', WD_ALIGN_PARAGRAPH.CENTER, 'center'), + ('left', None, None), + (None, WD_ALIGN_PARAGRAPH.LEFT, 'left'), + (None, None, None), + ]) + def alignment_set_fixture(self, request): + initial_jc_val, new_alignment_value, expected_jc_val = request.param + # paragraph -------------------- + p_bldr = a_p().with_nsdecls() + if initial_jc_val is not None: + p_bldr.with_child( + a_pPr().with_child( + a_jc().with_val(initial_jc_val)) + ) + p = p_bldr.element + paragraph = Paragraph(p) + # expected_xml ----------------- + pPr_bldr = a_pPr() + if expected_jc_val is not None: + pPr_bldr.with_child(a_jc().with_val(expected_jc_val)) + p_bldr = a_p().with_nsdecls().with_child(pPr_bldr) + expected_xml = p_bldr.xml() + return paragraph, new_alignment_value, expected_xml + @pytest.fixture def clear_fixture(self, request): """ From 158f2121bcd2c58b258dec1b83f8fef15316de19 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 27 Jun 2014 02:04:37 -0700 Subject: [PATCH 142/809] release: prepare v0.7.0 release --- HISTORY.rst | 12 ++++++++++++ docx/__init__.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 5f60b4227..fa4a711f3 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,18 @@ Release History --------------- +0.7.0 (2014-06-27) +++++++++++++++++++ + +- Add feature #68: Paragraph.insert_paragraph_before() +- Add feature #51: Paragraph.alignment (read/write) +- Add feature #61: Paragraph.text setter +- Add feature #58: Run.add_tab() +- Add feature #70: Run.clear() +- Add feature #60: Run.text setter +- Add feature #39: Run.text and Paragraph.text interpret '\n' and '\t' chars + + 0.6.0 (2014-06-22) ++++++++++++++++++ diff --git a/docx/__init__.py b/docx/__init__.py index 1bba46ad5..0741920a9 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.6.0' +__version__ = '0.7.0' # register custom Part classes with opc package reader From 767e4089fece402f2aad3ffc6314042d4da18282 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 28 Jun 2014 23:28:43 -0700 Subject: [PATCH 143/809] test: extract unitutil.mock and unitutil.file --- tests/image/test_bmp.py | 2 +- tests/image/test_gif.py | 2 +- tests/image/test_image.py | 6 ++-- tests/image/test_jpeg.py | 2 +- tests/image/test_png.py | 2 +- tests/image/test_tiff.py | 2 +- tests/opc/test_oxml.py | 6 ++-- tests/opc/test_package.py | 2 +- tests/opc/test_phys_pkg.py | 3 +- tests/opc/test_pkgreader.py | 2 +- tests/opc/test_pkgwriter.py | 2 +- tests/opc/test_rels.py | 2 +- tests/parts/test_document.py | 2 +- tests/parts/test_image.py | 5 ++- tests/parts/test_numbering.py | 2 +- tests/parts/test_styles.py | 2 +- tests/test_api.py | 2 +- tests/test_package.py | 5 ++- tests/test_shape.py | 2 +- tests/test_text.py | 2 +- tests/unitutil/__init__.py | 0 tests/unitutil/file.py | 34 +++++++++++++++++++ tests/{unitutil.py => unitutil/mock.py} | 44 ++----------------------- 23 files changed, 64 insertions(+), 69 deletions(-) create mode 100644 tests/unitutil/__init__.py create mode 100644 tests/unitutil/file.py rename tests/{unitutil.py => unitutil/mock.py} (79%) diff --git a/tests/image/test_bmp.py b/tests/image/test_bmp.py index f612dc998..812f55e90 100644 --- a/tests/image/test_bmp.py +++ b/tests/image/test_bmp.py @@ -12,7 +12,7 @@ from docx.image.constants import MIME_TYPE from docx.image.bmp import Bmp -from ..unitutil import initializer_mock +from ..unitutil.mock import initializer_mock class DescribeBmp(object): diff --git a/tests/image/test_gif.py b/tests/image/test_gif.py index 5c64cd364..2592f2854 100644 --- a/tests/image/test_gif.py +++ b/tests/image/test_gif.py @@ -12,7 +12,7 @@ from docx.image.constants import MIME_TYPE from docx.image.gif import Gif -from ..unitutil import initializer_mock +from ..unitutil.mock import initializer_mock class DescribeGif(object): diff --git a/tests/image/test_image.py b/tests/image/test_image.py index 6071e6e21..ed47eb465 100644 --- a/tests/image/test_image.py +++ b/tests/image/test_image.py @@ -18,9 +18,9 @@ from docx.image.tiff import Tiff from docx.opc.constants import CONTENT_TYPE as CT -from ..unitutil import ( - function_mock, class_mock, initializer_mock, instance_mock, method_mock, - test_file +from ..unitutil.file import test_file +from ..unitutil.mock import ( + function_mock, class_mock, initializer_mock, instance_mock, method_mock ) diff --git a/tests/image/test_jpeg.py b/tests/image/test_jpeg.py index 4caa61d57..3aaad4960 100644 --- a/tests/image/test_jpeg.py +++ b/tests/image/test_jpeg.py @@ -17,7 +17,7 @@ ) from docx.image.tiff import Tiff -from ..unitutil import ( +from ..unitutil.mock import ( call, class_mock, initializer_mock, instance_mock, method_mock ) diff --git a/tests/image/test_png.py b/tests/image/test_png.py index 45df3bd33..73f1e26ab 100644 --- a/tests/image/test_png.py +++ b/tests/image/test_png.py @@ -17,7 +17,7 @@ Png, _PngParser ) -from ..unitutil import ( +from ..unitutil.mock import ( call, class_mock, function_mock, initializer_mock, instance_mock, method_mock ) diff --git a/tests/image/test_tiff.py b/tests/image/test_tiff.py index 40b6d7da6..3e47d89c4 100644 --- a/tests/image/test_tiff.py +++ b/tests/image/test_tiff.py @@ -16,7 +16,7 @@ _LongIfdEntry, _RationalIfdEntry, _ShortIfdEntry, Tiff, _TiffParser ) -from ..unitutil import ( +from ..unitutil.mock import ( call, class_mock, function_mock, initializer_mock, instance_mock, loose_mock, method_mock ) diff --git a/tests/opc/test_oxml.py b/tests/opc/test_oxml.py index 12f60d665..79e77c880 100644 --- a/tests/opc/test_oxml.py +++ b/tests/opc/test_oxml.py @@ -8,8 +8,8 @@ from docx.opc.oxml import ( CT_Default, CT_Override, CT_Relationship, CT_Relationships, CT_Types ) +from docx.oxml.xmlchemy import serialize_for_reading -from ..unitutil import actual_xml from .unitdata.rels import ( a_Default, an_Override, a_Relationship, a_Relationships, a_Types ) @@ -76,7 +76,7 @@ def it_can_construct_a_new_relationships_element(self): '\n' ) - assert actual_xml(rels) == expected_xml + assert serialize_for_reading(rels) == expected_xml def it_can_build_rels_element_incrementally(self): # setup ------------------------ @@ -87,7 +87,7 @@ def it_can_build_rels_element_incrementally(self): rels.add_rel('rId3', 'http://reltype2', '../slides/slide1.xml') # verify ----------------------- expected_rels_xml = a_Relationships().xml - assert actual_xml(rels) == expected_rels_xml + assert serialize_for_reading(rels) == expected_rels_xml def it_can_generate_rels_file_xml(self): expected_xml = ( diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index e14d43788..fdb218585 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -16,7 +16,7 @@ ) from docx.opc.pkgreader import PackageReader -from ..unitutil import ( +from ..unitutil.mock import ( call, class_mock, cls_attr_mock, function_mock, instance_mock, loose_mock, method_mock, Mock, patch, PropertyMock ) diff --git a/tests/opc/test_phys_pkg.py b/tests/opc/test_phys_pkg.py index 53679c591..7e62cfd8e 100644 --- a/tests/opc/test_phys_pkg.py +++ b/tests/opc/test_phys_pkg.py @@ -22,7 +22,8 @@ _DirPkgReader, PhysPkgReader, PhysPkgWriter, _ZipPkgReader, _ZipPkgWriter ) -from ..unitutil import absjoin, class_mock, loose_mock, Mock, test_file_dir +from ..unitutil.file import absjoin, test_file_dir +from ..unitutil.mock import class_mock, loose_mock, Mock test_docx_path = absjoin(test_file_dir, 'test.docx') diff --git a/tests/opc/test_pkgreader.py b/tests/opc/test_pkgreader.py index bc5b0eb08..eaf5d8c9d 100644 --- a/tests/opc/test_pkgreader.py +++ b/tests/opc/test_pkgreader.py @@ -19,7 +19,7 @@ ) from .unitdata.types import a_Default, a_Types, an_Override -from ..unitutil import ( +from ..unitutil.mock import ( call, class_mock, function_mock, initializer_mock, instance_mock, loose_mock, method_mock, Mock, patch ) diff --git a/tests/opc/test_pkgwriter.py b/tests/opc/test_pkgwriter.py index a6eccc3ef..9e8806f13 100644 --- a/tests/opc/test_pkgwriter.py +++ b/tests/opc/test_pkgwriter.py @@ -13,7 +13,7 @@ from docx.opc.pkgwriter import _ContentTypesItem, PackageWriter from .unitdata.types import a_Default, a_Types, an_Override -from ..unitutil import ( +from ..unitutil.mock import ( call, class_mock, instance_mock, MagicMock, method_mock, Mock, patch ) diff --git a/tests/opc/test_rels.py b/tests/opc/test_rels.py index 66b557940..61036410c 100644 --- a/tests/opc/test_rels.py +++ b/tests/opc/test_rels.py @@ -13,7 +13,7 @@ from docx.opc.package import Part, _Relationship, Relationships from docx.opc.packuri import PackURI -from ..unitutil import ( +from ..unitutil.mock import ( call, class_mock, instance_mock, loose_mock, Mock, patch, PropertyMock ) diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 7821c4e11..1aed7ed82 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -28,7 +28,7 @@ a_gridCol, a_tbl, a_tblGrid, a_tblPr, a_tblW, a_tc, a_tr ) from ..oxml.unitdata.text import a_p, a_pPr, a_sectPr, an_r -from ..unitutil import ( +from ..unitutil.mock import ( function_mock, class_mock, initializer_mock, instance_mock, loose_mock, method_mock, Mock, property_mock ) diff --git a/tests/parts/test_image.py b/tests/parts/test_image.py index 88e348009..1e1ffc81f 100644 --- a/tests/parts/test_image.py +++ b/tests/parts/test_image.py @@ -15,9 +15,8 @@ from docx.package import Package from docx.parts.image import ImagePart -from ..unitutil import ( - initializer_mock, instance_mock, method_mock, test_file -) +from ..unitutil.file import test_file +from ..unitutil.mock import initializer_mock, instance_mock, method_mock class DescribeImagePart(object): diff --git a/tests/parts/test_numbering.py b/tests/parts/test_numbering.py index 257f6a756..e93ebe03c 100644 --- a/tests/parts/test_numbering.py +++ b/tests/parts/test_numbering.py @@ -16,7 +16,7 @@ from docx.parts.numbering import NumberingPart, _NumberingDefinitions from ..oxml.unitdata.numbering import a_num, a_numbering -from ..unitutil import ( +from ..unitutil.mock import ( function_mock, class_mock, initializer_mock, instance_mock, method_mock ) diff --git a/tests/parts/test_styles.py b/tests/parts/test_styles.py index 85db435f9..af845859d 100644 --- a/tests/parts/test_styles.py +++ b/tests/parts/test_styles.py @@ -16,7 +16,7 @@ from docx.parts.styles import StylesPart, _Styles from ..oxml.unitdata.styles import a_style, a_styles -from ..unitutil import ( +from ..unitutil.mock import ( function_mock, class_mock, initializer_mock, instance_mock, method_mock ) diff --git a/tests/test_api.py b/tests/test_api.py index 0a22606a2..dfc759c38 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -21,7 +21,7 @@ from docx.table import Table from docx.text import Paragraph, Run -from .unitutil import ( +from .unitutil.mock import ( instance_mock, class_mock, method_mock, property_mock, var_mock ) diff --git a/tests/test_package.py b/tests/test_package.py index 1dc7cf006..35812e924 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -13,9 +13,8 @@ from docx.package import ImageParts, Package from docx.parts.image import ImagePart -from .unitutil import ( - docx_path, class_mock, instance_mock, method_mock -) +from .unitutil.file import docx_path +from .unitutil.mock import class_mock, instance_mock, method_mock class DescribePackage(object): diff --git a/tests/test_shape.py b/tests/test_shape.py index 4c661c23b..abe26780e 100644 --- a/tests/test_shape.py +++ b/tests/test_shape.py @@ -21,7 +21,7 @@ an_spPr, an_xfrm ) from .oxml.unitdata.text import an_r -from .unitutil import instance_mock +from .unitutil.mock import instance_mock class DescribeInlineShape(object): diff --git a/tests/test_text.py b/tests/test_text.py index 009e113a7..f1a07ab99 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -24,7 +24,7 @@ an_imprint, an_oMath, a_noProof, an_outline, an_r, an_rPr, an_rStyle, an_rtl ) -from .unitutil import call, class_mock, instance_mock, Mock +from .unitutil.mock import call, class_mock, instance_mock, Mock class DescribeParagraph(object): diff --git a/tests/unitutil/__init__.py b/tests/unitutil/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unitutil/file.py b/tests/unitutil/file.py new file mode 100644 index 000000000..968a47d1d --- /dev/null +++ b/tests/unitutil/file.py @@ -0,0 +1,34 @@ +# encoding: utf-8 + +""" +Utility functions for loading files for unit testing +""" + +import os + + +_thisdir = os.path.split(__file__)[0] +test_file_dir = os.path.abspath(os.path.join(_thisdir, '..', 'test_files')) + + +def abspath(relpath): + thisdir = os.path.split(__file__)[0] + return os.path.abspath(os.path.join(thisdir, relpath)) + + +def absjoin(*paths): + return os.path.abspath(os.path.join(*paths)) + + +def docx_path(name): + """ + Return the absolute path to test .docx file with root name *name*. + """ + return absjoin(test_file_dir, '%s.docx' % name) + + +def test_file(name): + """ + Return the absolute path to test file having *name*. + """ + return absjoin(test_file_dir, name) diff --git a/tests/unitutil.py b/tests/unitutil/mock.py similarity index 79% rename from tests/unitutil.py rename to tests/unitutil/mock.py index 5c7aaf2e9..aa3d8f2ef 100644 --- a/tests/unitutil.py +++ b/tests/unitutil/mock.py @@ -1,10 +1,11 @@ # encoding: utf-8 """ -Utility functions for unit testing +Utility functions wrapping the excellent *mock* library. """ -import os +from __future__ import absolute_import, print_function + import sys if sys.version_info >= (3, 3): @@ -17,45 +18,6 @@ from mock import create_autospec, Mock, patch, PropertyMock -from docx.oxml.xmlchemy import serialize_for_reading - - -_thisdir = os.path.split(__file__)[0] -test_file_dir = os.path.abspath(os.path.join(_thisdir, 'test_files')) - - -def abspath(relpath): - thisdir = os.path.split(__file__)[0] - return os.path.abspath(os.path.join(thisdir, relpath)) - - -def actual_xml(elm): - return serialize_for_reading(elm) - - -def absjoin(*paths): - return os.path.abspath(os.path.join(*paths)) - - -def docx_path(name): - """ - Return the absolute path to test .docx file with root name *name*. - """ - return absjoin(_thisdir, 'test_files', '%s.docx' % name) - - -def test_file(name): - """ - Return the absolute path to test file having *name*. - """ - return absjoin(_thisdir, 'test_files', name) - - -# =========================================================================== -# pytest mocking helpers -# =========================================================================== - - def class_mock(request, q_class_name, autospec=True, **kwargs): """ Return a mock patching the class with qualified name *q_class_name*. From 1769f0423dcb40e153534d111ee09ae87ba77b28 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 29 Jun 2014 00:15:21 -0700 Subject: [PATCH 144/809] test: transplant tests.unitutil.cxml --- requirements.txt | 1 + setup.py | 2 +- tests/unitutil/cxml.py | 264 +++++++++++++++++++++++++++++++++++++++++ tox.ini | 3 + 4 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 tests/unitutil/cxml.py diff --git a/requirements.txt b/requirements.txt index 6e5250cf6..de244afa3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ behave>=1.2.3 flake8>=2.0 lxml>=3.1.0 mock>=1.0.1 +pyparsing>=2.0.1 pytest>=2.5 diff --git a/setup.py b/setup.py index a4b1b57a9..bb248dafc 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ def text_of(relpath): INSTALL_REQUIRES = ['lxml>=2.3.2'] TEST_SUITE = 'tests' -TESTS_REQUIRE = ['behave', 'mock', 'pytest'] +TESTS_REQUIRE = ['behave', 'mock', 'pyparsing', 'pytest'] CLASSIFIERS = [ 'Development Status :: 3 - Alpha', diff --git a/tests/unitutil/cxml.py b/tests/unitutil/cxml.py new file mode 100644 index 000000000..e39babf4d --- /dev/null +++ b/tests/unitutil/cxml.py @@ -0,0 +1,264 @@ +# encoding: utf-8 + +""" +Parser for Compact XML Expression Language (CXEL) ('see-ex-ell'), a compact +XML specification language I made up that's useful for producing XML element +trees suitable for unit testing. +""" + +from __future__ import print_function + +from pyparsing import ( + alphas, alphanums, Combine, dblQuotedString, delimitedList, Forward, + Group, Literal, Optional, removeQuotes, stringEnd, Suppress, Word +) + +from docx.oxml import parse_xml +from docx.oxml.ns import nsmap + + +# ==================================================================== +# api functions +# ==================================================================== + +def element(cxel_str): + """ + Return an oxml element parsed from the XML generated from *cxel_str*. + """ + _xml = xml(cxel_str) + return parse_xml(_xml) + + +def xml(cxel_str): + """ + Return the XML generated from *cxel_str*. + """ + root_token = root_node.parseString(cxel_str) + xml = root_token.element.xml + return xml + + +# ==================================================================== +# internals +# ==================================================================== + + +def nsdecls(*nspfxs): + """ + Return a string containing a namespace declaration for each of *nspfxs*, + in the order they are specified. + """ + nsdecls = '' + for nspfx in nspfxs: + nsdecls += ' xmlns:%s="%s"' % (nspfx, nsmap[nspfx]) + return nsdecls + + +class Element(object): + """ + Represents an XML element, having a namespace, tagname, attributes, and + may contain either text or children (but not both) or may be empty. + """ + def __init__(self, tagname, attrs, text): + self._tagname = tagname + self._attrs = attrs + self._text = text + self._children = [] + self._is_root = False + + def __repr__(self): + """ + Provide a more meaningful repr value for an Element object, one that + displays the tagname as a simple empty element, e.g. ````. + """ + return "<%s/>" % self._tagname + + def connect_children(self, child_node_list): + """ + Make each of the elements appearing in *child_node_list* a child of + this element. + """ + for node in child_node_list: + child = node.element + self._children.append(child) + + @classmethod + def from_token(cls, token): + """ + Return an ``Element`` object constructed from a parser element token. + """ + tagname = token.tagname + attrs = [(name, value) for name, value in token.attr_list] + text = token.text + return cls(tagname, attrs, text) + + @property + def is_root(self): + """ + |True| if this element is the root of the tree and should include the + namespace prefixes. |False| otherwise. + """ + return self._is_root + + @is_root.setter + def is_root(self, value): + self._is_root = bool(value) + + @property + def nspfx(self): + """ + The namespace prefix of this element, the empty string (``''``) if + the tag is in the default namespace. + """ + tagname = self._tagname + idx = tagname.find(':') + if idx == -1: + return '' + return tagname[:idx] + + @property + def nspfxs(self): + """ + A sequence containing each of the namespace prefixes appearing in + this tree. Each prefix appears once and only once, and in document + order. + """ + def merge(seq, seq_2): + for item in seq_2: + if item in seq: + continue + seq.append(item) + + nspfxs = [self.nspfx] + for child in self._children: + merge(nspfxs, child.nspfxs) + return nspfxs + + @property + def xml(self): + """ + The XML corresponding to the tree rooted at this element, + pretty-printed using 2-spaces indentation at each level and with + a trailing '\n'. + """ + return self._xml(indent=0) + + def _xml(self, indent): + """ + Return a string containing the XML of this element and all its + children with a starting indent of *indent* spaces. + """ + self._indent_str = ' ' * indent + xml = self._start_tag + for child in self._children: + xml += child._xml(indent+2) + xml += self._end_tag + return xml + + @property + def _start_tag(self): + """ + The text of the opening tag of this element, including attributes. If + this is the root element, a namespace declaration for each of the + namespace prefixes that occur in this tree is added in front of any + attributes. If this element contains text, that text follows the + start tag. If not, and this element has no children, an empty tag is + returned. Otherwise, an opening tag is returned, followed by + a newline. The tag is indented by this element's indent value in all + cases. + """ + _nsdecls = nsdecls(*self.nspfxs) if self.is_root else '' + tag = '%s<%s%s' % (self._indent_str, self._tagname, _nsdecls) + for attr in self._attrs: + name, value = attr + tag += ' %s="%s"' % (name, value) + if self._text: + tag += '>%s' % self._text + elif self._children: + tag += '>\n' + else: + tag += '/>\n' + return tag + + @property + def _end_tag(self): + """ + The text of the closing tag of this element, if there is one. If the + element contains text, no leading indentation is included. + """ + if self._text: + tag = '\n' % self._tagname + elif self._children: + tag = '%s\n' % (self._indent_str, self._tagname) + else: + tag = '' + return tag + + +# ==================================================================== +# parser +# ==================================================================== + +# parse actions ---------------------------------- + +def connect_node_children(s, loc, tokens): + node = tokens[0] + node.element.connect_children(node.child_node_list) + + +def connect_root_node_children(root_node): + root_node.element.connect_children(root_node.child_node_list) + root_node.element.is_root = True + + +def grammar(): + # terminals ---------------------------------- + colon = Literal(':') + equal = Suppress('=') + slash = Suppress('/') + open_paren = Suppress('(') + close_paren = Suppress(')') + open_brace = Suppress('{') + close_brace = Suppress('}') + + # np:tagName --------------------------------- + nspfx = Word(alphas) + local_name = Word(alphas) + tagname = Combine(nspfx + colon + local_name) + + # np:attr_name=attr_val ---------------------- + attr_name = Word(alphas + ':') + attr_val = Word(alphanums + '-.') + attr_def = Group(attr_name + equal + attr_val) + attr_list = open_brace + delimitedList(attr_def) + close_brace + + text = dblQuotedString.setParseAction(removeQuotes) + + # w:jc{val=right} ---------------------------- + element = ( + 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') + ).setParseAction(connect_node_children) + + child_node_list << ( + open_paren + delimitedList(node) + close_paren + | node + ) + + root_node = ( + element('element') + + Group(Optional(slash + child_node_list))('child_node_list') + + stringEnd + ).setParseAction(connect_root_node_children) + + return root_node + +root_node = grammar() diff --git a/tox.ini b/tox.ini index cc76cb70b..d463f248f 100644 --- a/tox.ini +++ b/tox.ini @@ -15,6 +15,7 @@ deps = behave lxml mock + pyparsing pytest commands = @@ -25,10 +26,12 @@ commands = deps = behave lxml + pyparsing pytest [testenv:py34] deps = behave lxml + pyparsing pytest From 5c0eb0fe419a3bdf244f54c97cdba9ebb189c24f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 29 Jun 2014 01:21:34 -0700 Subject: [PATCH 145/809] test: refactor test_section to use cxml --- tests/test_section.py | 158 ++++++++++++++++-------------------------- 1 file changed, 60 insertions(+), 98 deletions(-) diff --git a/tests/test_section.py b/tests/test_section.py index 709b51c4a..afc8cc756 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -12,7 +12,8 @@ from docx.section import Section from docx.shared import Inches -from .oxml.unitdata.section import a_pgMar, a_pgSz, a_sectPr, a_type +from .oxml.unitdata.section import a_pgMar, a_sectPr +from .unitutil.cxml import element, xml class DescribeSection(object): @@ -33,6 +34,7 @@ def it_knows_its_page_width(self, page_width_get_fixture): def it_can_change_its_page_width(self, page_width_set_fixture): section, new_page_width, expected_xml = page_width_set_fixture section.page_width = new_page_width + assert section._sectPr.xml == expected_xml def it_knows_its_page_height(self, page_height_get_fixture): section, expected_page_height = page_height_get_fixture @@ -128,120 +130,100 @@ def margins_set_fixture(self, request): return section, property_name, new_value, expected_xml @pytest.fixture(params=[ - (True, 'landscape', WD_ORIENT.LANDSCAPE), - (True, 'portrait', WD_ORIENT.PORTRAIT), - (True, None, WD_ORIENT.PORTRAIT), - (False, None, WD_ORIENT.PORTRAIT), + ('w:sectPr/w:pgSz{w:orient=landscape}', WD_ORIENT.LANDSCAPE), + ('w:sectPr/w:pgSz{w:orient=portrait}', WD_ORIENT.PORTRAIT), + ('w:sectPr/w:pgSz', WD_ORIENT.PORTRAIT), + ('w:sectPr', WD_ORIENT.PORTRAIT), ]) def orientation_get_fixture(self, request): - has_pgSz_child, orient, expected_orientation = request.param - pgSz_bldr = self.pgSz_bldr(has_pgSz_child, orient=orient) - sectPr = self.sectPr_bldr(pgSz_bldr).element - section = Section(sectPr) + sectPr_cxml, expected_orientation = request.param + section = Section(element(sectPr_cxml)) return section, expected_orientation @pytest.fixture(params=[ - (WD_ORIENT.LANDSCAPE, 'landscape'), - (WD_ORIENT.PORTRAIT, None), - (None, None), + (WD_ORIENT.LANDSCAPE, 'w:sectPr/w:pgSz{w:orient=landscape}'), + (WD_ORIENT.PORTRAIT, 'w:sectPr/w:pgSz'), + (None, 'w:sectPr/w:pgSz'), ]) def orientation_set_fixture(self, request): - new_orientation, expected_orient_val = request.param - # section ---------------------- - sectPr = self.sectPr_bldr().element - section = Section(sectPr) - # expected_xml ----------------- - pgSz_bldr = self.pgSz_bldr(orient=expected_orient_val) - expected_xml = self.sectPr_bldr(pgSz_bldr).xml() + new_orientation, expected_cxml = request.param + section = Section(element('w:sectPr')) + expected_xml = xml(expected_cxml) return section, new_orientation, expected_xml @pytest.fixture(params=[ - (True, 2880, Inches(2)), - (True, None, None), - (False, None, None), + ('w:sectPr/w:pgSz{w:h=2880}', Inches(2)), + ('w:sectPr/w:pgSz', None), + ('w:sectPr', None), ]) def page_height_get_fixture(self, request): - has_pgSz_child, h, expected_page_height = request.param - pgSz_bldr = self.pgSz_bldr(has_pgSz_child, h=h) - sectPr = self.sectPr_bldr(pgSz_bldr).element - section = Section(sectPr) + sectPr_cxml, expected_page_height = request.param + section = Section(element(sectPr_cxml)) return section, expected_page_height @pytest.fixture(params=[ - (None, None), - (Inches(2), 2880), + (None, 'w:sectPr/w:pgSz'), + (Inches(2), 'w:sectPr/w:pgSz{w:h=2880}'), ]) def page_height_set_fixture(self, request): - new_page_height, expected_h_val = request.param - # section ---------------------- - sectPr = self.sectPr_bldr().element - section = Section(sectPr) - # expected_xml ----------------- - pgSz_bldr = self.pgSz_bldr(h=expected_h_val) - expected_xml = self.sectPr_bldr(pgSz_bldr).xml() + new_page_height, expected_cxml = request.param + section = Section(element('w:sectPr')) + expected_xml = xml(expected_cxml) return section, new_page_height, expected_xml @pytest.fixture(params=[ - (True, 1440, Inches(1)), - (True, None, None), - (False, None, None), + ('w:sectPr/w:pgSz{w:w=1440}', Inches(1)), + ('w:sectPr/w:pgSz', None), + ('w:sectPr', None), ]) def page_width_get_fixture(self, request): - has_pgSz_child, w, expected_page_width = request.param - pgSz_bldr = self.pgSz_bldr(has_pgSz_child, w=w) - sectPr = self.sectPr_bldr(pgSz_bldr).element - section = Section(sectPr) + sectPr_cxml, expected_page_width = request.param + section = Section(element(sectPr_cxml)) return section, expected_page_width @pytest.fixture(params=[ - (None, None), - (Inches(1), 1440), + (None, 'w:sectPr/w:pgSz'), + (Inches(4), 'w:sectPr/w:pgSz{w:w=5760}'), ]) def page_width_set_fixture(self, request): - new_page_width, expected_w_val = request.param - # section ---------------------- - sectPr = self.sectPr_bldr().element - section = Section(sectPr) - # expected_xml ----------------- - pgSz_bldr = self.pgSz_bldr(w=expected_w_val) - expected_xml = self.sectPr_bldr(pgSz_bldr).xml() + new_page_width, expected_cxml = request.param + section = Section(element('w:sectPr')) + expected_xml = xml(expected_cxml) return section, new_page_width, expected_xml @pytest.fixture(params=[ - (False, None, WD_SECTION.NEW_PAGE), - (True, None, WD_SECTION.NEW_PAGE), - (True, 'continuous', WD_SECTION.CONTINUOUS), - (True, 'nextPage', WD_SECTION.NEW_PAGE), - (True, 'oddPage', WD_SECTION.ODD_PAGE), - (True, 'evenPage', WD_SECTION.EVEN_PAGE), - (True, 'nextColumn', WD_SECTION.NEW_COLUMN), + ('w:sectPr', WD_SECTION.NEW_PAGE), + ('w:sectPr/w:type', WD_SECTION.NEW_PAGE), + ('w:sectPr/w:type{w:val=continuous}', WD_SECTION.CONTINUOUS), + ('w:sectPr/w:type{w:val=nextPage}', WD_SECTION.NEW_PAGE), + ('w:sectPr/w:type{w:val=oddPage}', WD_SECTION.ODD_PAGE), + ('w:sectPr/w:type{w:val=evenPage}', WD_SECTION.EVEN_PAGE), + ('w:sectPr/w:type{w:val=nextColumn}', WD_SECTION.NEW_COLUMN), ]) def start_type_get_fixture(self, request): - has_type_child, type_val, expected_start_type = request.param - type_bldr = self.type_bldr(has_type_child, type_val) - sectPr = self.sectPr_bldr(type_bldr).element - section = Section(sectPr) + sectPr_cxml, expected_start_type = request.param + section = Section(element(sectPr_cxml)) return section, expected_start_type @pytest.fixture(params=[ - (True, 'oddPage', WD_SECTION.EVEN_PAGE, True, 'evenPage'), - (True, 'nextPage', None, False, None), - (False, None, WD_SECTION.NEW_PAGE, False, None), - (True, 'continuous', WD_SECTION.NEW_PAGE, False, None), - (True, None, WD_SECTION.NEW_PAGE, False, None), - (True, None, WD_SECTION.NEW_COLUMN, True, 'nextColumn'), + ('w:sectPr/w:type{w:val=oddPage}', WD_SECTION.EVEN_PAGE, + 'w:sectPr/w:type{w:val=evenPage}'), + ('w:sectPr/w:type{w:val=nextPage}', None, + 'w:sectPr'), + ('w:sectPr', None, + 'w:sectPr'), + ('w:sectPr/w:type{w:val=continuous}', WD_SECTION.NEW_PAGE, + 'w:sectPr'), + ('w:sectPr/w:type', WD_SECTION.NEW_PAGE, + 'w:sectPr'), + ('w:sectPr/w:type', WD_SECTION.NEW_COLUMN, + 'w:sectPr/w:type{w:val=nextColumn}'), ]) def start_type_set_fixture(self, request): - (has_type_child, initial_type_val, new_type, has_type_child_after, - expected_type_val) = request.param - # section ---------------------- - type_bldr = self.type_bldr(has_type_child, initial_type_val) - sectPr = self.sectPr_bldr(type_bldr).element - section = Section(sectPr) - # expected_xml ----------------- - type_bldr = self.type_bldr(has_type_child_after, expected_type_val) - expected_xml = self.sectPr_bldr(type_bldr).xml() - return section, new_type, expected_xml + initial_cxml, new_start_type, expected_cxml = request.param + section = Section(element(initial_cxml)) + expected_xml = xml(expected_cxml) + return section, new_start_type, expected_xml # fixture components --------------------------------------------- @@ -262,29 +244,9 @@ def pgMar_bldr(self, **kwargs): set_attr_method(value) return pgMar_bldr - def pgSz_bldr(self, has_pgSz=True, w=None, h=None, orient=None): - if not has_pgSz: - return None - pgSz_bldr = a_pgSz() - if w is not None: - pgSz_bldr.with_w(w) - if h is not None: - pgSz_bldr.with_h(h) - if orient is not None: - pgSz_bldr.with_orient(orient) - return pgSz_bldr - def sectPr_bldr(self, *child_bldrs): sectPr_bldr = a_sectPr().with_nsdecls() for child_bldr in child_bldrs: if child_bldr is not None: sectPr_bldr.with_child(child_bldr) return sectPr_bldr - - def type_bldr(self, has_type_elm, val): - if not has_type_elm: - return None - type_bldr = a_type() - if val is not None: - type_bldr.with_val(val) - return type_bldr From 3b1abdaecb1619de6bca615da0340f627eda2706 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 29 Jun 2014 02:10:47 -0700 Subject: [PATCH 146/809] test: refactor oxml.parts.test_document to cxml --- tests/oxml/parts/test_document.py | 62 +++++++++++++------------------ 1 file changed, 25 insertions(+), 37 deletions(-) diff --git a/tests/oxml/parts/test_document.py b/tests/oxml/parts/test_document.py index 646439313..aa1bc5b05 100644 --- a/tests/oxml/parts/test_document.py +++ b/tests/oxml/parts/test_document.py @@ -8,33 +8,15 @@ import pytest -from .unitdata.document import a_body -from ..unitdata.section import a_type -from ..unitdata.text import a_p, a_pPr, a_sectPr +from ...unitutil.cxml import element, xml class DescribeCT_Body(object): - def it_can_clear_all_the_content_it_holds(self): - """ - Remove all content child elements from this element. - """ - cases = ( - (a_body().with_nsdecls(), - a_body().with_nsdecls()), - (a_body().with_nsdecls().with_child(a_p()), - a_body().with_nsdecls()), - (a_body().with_nsdecls().with_child(a_sectPr()), - a_body().with_nsdecls().with_child(a_sectPr())), - (a_body().with_nsdecls().with_child(a_p()).with_child(a_sectPr()), - a_body().with_nsdecls().with_child(a_sectPr())), - ) - for before_body_bldr, after_body_bldr in cases: - body = before_body_bldr.element - # exercise ----------------- - body.clear_content() - # verify ------------------- - assert body.xml == after_body_bldr.xml() + def it_can_clear_all_its_content(self, clear_fixture): + body, expected_xml = clear_fixture + body.clear_content() + assert body.xml == expected_xml def it_can_add_a_section_break(self, section_break_fixture): body, expected_xml = section_break_fixture @@ -44,20 +26,26 @@ def it_can_add_a_section_break(self, section_break_fixture): # fixtures ------------------------------------------------------- + @pytest.fixture(params=[ + ('w:body', 'w:body'), + ('w:body/w:p', 'w:body'), + ('w:body/w:tbl', '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 = element(before_cxml) + expected_xml = xml(after_cxml) + return body, expected_xml + @pytest.fixture def section_break_fixture(self): - body = ( - a_body().with_nsdecls().with_child( - a_sectPr().with_child( - a_type().with_val('foobar'))) - ).element - expected_xml = ( - a_body().with_nsdecls().with_child( - a_p().with_child( - a_pPr().with_child( - a_sectPr().with_child( - a_type().with_val('foobar'))))).with_child( - a_sectPr().with_child( - a_type().with_val('foobar'))) - ).xml() + body = element('w:body/w:sectPr/w:type{w:val=foobar}') + expected_xml = xml( + 'w:body/(' + ' w:p/w:pPr/w:sectPr/w:type{w:val=foobar},' + ' w:sectPr/w:type{w:val=foobar}' + ')' + ) return body, expected_xml From 6ebedac6021ffae5623ea3e79bdf1ff67e2a84bd Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 29 Jun 2014 02:43:46 -0700 Subject: [PATCH 147/809] test: refactor oxml.test_text.py to use cxml --- docx/oxml/text.py | 12 ++-- tests/oxml/test_text.py | 118 +++++++--------------------------------- 2 files changed, 25 insertions(+), 105 deletions(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 88264dad2..46bf1d54c 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -85,8 +85,8 @@ def set_sectPr(self, sectPr): @property def style(self): """ - String contained in w:val attribute of child, or - None if that element is not present. + String contained in w:val attribute of ./w:pPr/w:pStyle grandchild, + or |None| if not present. """ pPr = self.pPr if pPr is None: @@ -95,10 +95,6 @@ def style(self): @style.setter def style(self, style): - """ - Set style of this element to *style*. If *style* is None, - remove the style element. - """ pPr = self.get_or_add_pPr() pPr.style = style @@ -256,8 +252,8 @@ def text(self, text): @property def underline(self): """ - String contained in w:val attribute of grandchild, or |None| if - that element is not present. + String contained in w:val attribute of ./w:rPr/w:u grandchild, or + |None| if not present. """ rPr = self.rPr if rPr is None: diff --git a/tests/oxml/test_text.py b/tests/oxml/test_text.py index b53016d1d..974e19504 100644 --- a/tests/oxml/test_text.py +++ b/tests/oxml/test_text.py @@ -4,106 +4,30 @@ Test suite for the docx.oxml.text module. """ -from docx.oxml.text import CT_R, CT_Text +from __future__ import absolute_import, print_function, unicode_literals -from .unitdata.text import a_p, a_pPr, a_pStyle, a_t, an_r +import pytest - -class DescribeCT_P(object): - - def it_has_a_sequence_of_the_runs_it_contains(self): - p = a_p().with_nsdecls().with_child(an_r()).with_child(an_r()).element - assert len(p.r_lst) == 2 - for r in p.r_lst: - assert isinstance(r, CT_R) - - def it_can_add_an_r_to_itself(self): - p = a_p().with_nsdecls().element - # exercise ----------------- - r = p.add_r() - # verify ------------------- - assert p.xml == a_p().with_nsdecls().with_child(an_r()).xml() - assert isinstance(r, CT_R) - - def it_knows_its_paragraph_style(self): - pPr_bldr = a_pPr().with_child(a_pStyle().with_val('foobar')) - cases = ( - (a_p(), None), - (a_p().with_child(pPr_bldr), 'foobar'), - ) - for builder, expected_value in cases: - p = builder.with_nsdecls().element - assert p.style == expected_value - - def it_can_set_its_paragraph_style(self): - pPr = a_pPr().with_child(a_pStyle().with_val('foobar')) - pPr2 = a_pPr().with_child(a_pStyle().with_val('barfoo')) - cases = ( - (1, a_p(), None, a_p().with_child(a_pPr())), - (2, a_p(), 'foobar', a_p().with_child(pPr)), - (3, a_p().with_child(pPr), None, a_p().with_child(a_pPr())), - (4, a_p().with_child(pPr), 'barfoo', a_p().with_child(pPr2)), - ) - for case_nmbr, before_bldr, new_style, after_bldr in cases: - p = before_bldr.with_nsdecls().element - p.style = new_style - expected_xml = after_bldr.with_nsdecls().xml() - assert p.xml == expected_xml - - -class DescribeCT_PPr(object): - - def it_knows_the_paragraph_style(self): - cases = ( - (a_pPr(), None), - (a_pPr().with_child(a_pStyle().with_val('foobar')), 'foobar'), - ) - for builder, expected_value in cases: - pPr = builder.with_nsdecls().element - assert pPr.style == expected_value - - def it_can_set_the_paragraph_style(self): - cases = ( - (1, a_pPr(), None, a_pPr()), - (2, a_pPr(), 'foobar', - a_pPr().with_child(a_pStyle().with_val('foobar'))), - (3, a_pPr().with_child(a_pStyle().with_val('foobar')), None, - a_pPr()), - (4, a_pPr().with_child(a_pStyle().with_val('foobar')), 'barfoo', - a_pPr().with_child(a_pStyle().with_val('barfoo'))), - ) - for case_nmbr, before_bldr, new_style, after_bldr in cases: - pPr = before_bldr.with_nsdecls().element - pPr.style = new_style - expected_xml = after_bldr.with_nsdecls().xml() - assert pPr.xml == expected_xml +from ..unitutil.cxml import element, xml class DescribeCT_R(object): - def it_can_add_a_t_to_itself(self): - text = 'foobar' - r = an_r().with_nsdecls().element - # exercise ----------------- - t = r.add_t(text) - # verify ------------------- - assert ( - r.xml == - an_r().with_nsdecls().with_child(a_t().with_text(text)).xml() - ) - assert isinstance(t, CT_Text) - - def it_has_a_sequence_of_the_t_elms_it_contains(self): - cases = ( - (an_r().with_nsdecls(), 0), - (an_r().with_nsdecls().with_child( - a_t().with_text('foo')), 1), - (an_r().with_nsdecls().with_child( - a_t().with_text('foo')).with_child( - a_t().with_text('bar')), 2), - ) - for r_bldr, expected_len in cases: - r = r_bldr.element - assert len(r.t_lst) == expected_len - for t in r.t_lst: - assert isinstance(t, CT_Text) + def it_can_add_a_t_preserving_edge_whitespace(self, add_t_fixture): + r, text, expected_xml = add_t_fixture + r.add_t(text) + assert r.xml == expected_xml + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ('w:r', 'foobar', 'w:r/w:t"foobar"'), + ('w:r', 'foobar ', 'w:r/w:t{xml:space=preserve}"foobar "'), + ('w:r/(w:rPr/w:rStyle{w:val=emphasis}, w:cr)', 'foobar', + 'w:r/(w:rPr/w:rStyle{w:val=emphasis}, w:cr, w:t"foobar")'), + ]) + def add_t_fixture(self, request): + initial_cxml, text, expected_cxml = request.param + r = element(initial_cxml) + expected_xml = xml(expected_cxml) + return r, text, expected_xml From 4321a405dd7b685a780e97a059d3f9c9c60a6823 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 29 Jun 2014 03:27:05 -0700 Subject: [PATCH 148/809] test: refactor parts.test_document.py to use cxml --- tests/parts/test_document.py | 128 +++++++++++++---------------------- 1 file changed, 46 insertions(+), 82 deletions(-) diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 1aed7ed82..60d103bbf 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -22,12 +22,12 @@ from docx.table import Table from docx.text import Paragraph -from ..oxml.unitdata.dml import a_drawing, an_inline from ..oxml.parts.unitdata.document import a_body, a_document from ..oxml.unitdata.table import ( a_gridCol, a_tbl, a_tblGrid, a_tblPr, a_tblW, a_tc, a_tr ) -from ..oxml.unitdata.text import a_p, a_pPr, a_sectPr, an_r +from ..oxml.unitdata.text import a_p, a_sectPr +from ..unitutil.cxml import element, xml from ..unitutil.mock import ( function_mock, class_mock, initializer_mock, instance_mock, loose_mock, method_mock, Mock, property_mock @@ -400,24 +400,22 @@ def it_can_add_a_table(self, add_table_fixture): assert body._body.xml == expected_xml assert isinstance(table, Table) - def it_can_clear_itself_of_all_content_it_holds( - self, clear_content_fixture): - body, expected_xml = clear_content_fixture + def it_can_clear_itself_of_all_content_it_holds(self, clear_fixture): + body, expected_xml = clear_fixture _body = body.clear_content() assert body._body.xml == expected_xml assert _body is body def it_provides_access_to_the_paragraphs_it_contains( - self, body_with_paragraphs): - body = body_with_paragraphs + self, paragraphs_fixture): + body = paragraphs_fixture paragraphs = body.paragraphs assert len(paragraphs) == 2 for p in paragraphs: assert isinstance(p, Paragraph) - def it_provides_access_to_the_tables_it_contains( - self, body_with_tables): - body = body_with_tables + def it_provides_access_to_the_tables_it_contains(self, tables_fixture): + body = tables_fixture tables = body.tables assert len(tables) == 2 for table in tables: @@ -426,18 +424,15 @@ def it_provides_access_to_the_tables_it_contains( # fixtures ------------------------------------------------------- @pytest.fixture(params=[ - (0, False), (1, False), (0, True), (1, True) + ('w:body', 'w:body/w:p'), + ('w:body/w:p', 'w:body/(w:p, w:p)'), + ('w:body/w:sectPr', 'w:body/(w:p, w:sectPr)'), + ('w:body/(w:p, w:sectPr)', 'w:body/(w:p, w:p, w:sectPr)'), ]) def add_paragraph_fixture(self, request): - p_count, has_sectPr = request.param - # body element ----------------- - body_bldr = self._body_bldr(p_count=p_count, sectPr=has_sectPr) - body_elm = body_bldr.element - body = _Body(body_elm) - # expected XML ----------------- - p_count += 1 - body_bldr = self._body_bldr(p_count=p_count, sectPr=has_sectPr) - expected_xml = body_bldr.xml() + before_cxml, after_cxml = request.param + body = _Body(element(before_cxml)) + expected_xml = xml(after_cxml) return body, expected_xml @pytest.fixture(params=[(0, False), (0, True), (1, False), (1, True)]) @@ -454,42 +449,27 @@ def add_table_fixture(self, request): return body, expected_xml + @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)) + expected_xml = xml(after_cxml) + return body, expected_xml + @pytest.fixture - def body_with_paragraphs(self): - body_elm = ( - a_body().with_nsdecls() - .with_child(a_p()) - .with_child(a_p()) - .element - ) - return _Body(body_elm) + def paragraphs_fixture(self): + return _Body(element('w:body/(w:p, w:p)')) @pytest.fixture - def body_with_tables(self): - body_elm = ( - a_body().with_nsdecls() - .with_child(a_tbl()) - .with_child(a_tbl()) - .element - ) - return _Body(body_elm) + def tables_fixture(self): + return _Body(element('w:body/(w:tbl, w:tbl)')) - @pytest.fixture(params=[False, True]) - def clear_content_fixture(self, request): - has_sectPr = request.param - # body element ----------------- - body_bldr = a_body().with_nsdecls() - body_bldr.with_child(a_p()) - if has_sectPr: - body_bldr.with_child(a_sectPr()) - body_elm = body_bldr.element - body = _Body(body_elm) - # expected XML ----------------- - body_bldr = a_body().with_nsdecls() - if has_sectPr: - body_bldr.with_child(a_sectPr()) - expected_xml = body_bldr.xml() - return body, expected_xml + # fixture components --------------------------------------------- def _body_bldr(self, p_count=0, tbl_bldr=None, sectPr=False): body_bldr = a_body().with_nsdecls() @@ -535,8 +515,8 @@ class DescribeInlineShapes(object): def it_knows_how_many_inline_shapes_it_contains( self, inline_shapes_fixture): - inline_shapes, inline_shape_count = inline_shapes_fixture - assert len(inline_shapes) == inline_shape_count + inline_shapes, expected_count = inline_shapes_fixture + assert len(inline_shapes) == expected_count def it_can_iterate_over_its_InlineShape_instances( self, inline_shapes_fixture): @@ -599,6 +579,17 @@ def add_picture_fixture( image_part_, rId_, shape_id_, new_picture_shape_ ) + @pytest.fixture + def inline_shapes_fixture(self): + body = element( + 'w:body/w:p/(w:r/w:drawing/wp:inline, w:r/w:drawing/wp:inline)' + ) + inline_shapes = InlineShapes(body, None) + expected_count = 2 + return inline_shapes, expected_count + + # fixture components --------------------------------------------- + @pytest.fixture def body_(self, request, r_): body_ = instance_mock(request, CT_Body) @@ -626,25 +617,6 @@ def InlineShape_(self, request, new_picture_shape_): InlineShape_.new_picture.return_value = new_picture_shape_ return InlineShape_ - @pytest.fixture - def inline_shapes_fixture(self): - inline_shape_count = 2 - body = ( - a_body().with_nsdecls('w', 'wp').with_child( - a_p().with_child( - an_r().with_child( - a_drawing().with_child( - an_inline()))).with_child( - an_r().with_child( - a_drawing().with_child( - an_inline()) - ) - ) - ) - ).element - inline_shapes = InlineShapes(body, None) - return inline_shapes, inline_shape_count - @pytest.fixture def inline_shapes_with_parent_(self, request): parent_ = loose_mock(request, name='parent_') @@ -672,7 +644,6 @@ class DescribeSections(object): def it_knows_how_many_sections_it_contains(self, len_fixture): sections, expected_len = len_fixture - print(sections._document_elm.xml) assert len(sections) == expected_len def it_can_iterate_over_its_Section_instances(self, iter_fixture): @@ -710,11 +681,4 @@ def len_fixture(self, document_elm): @pytest.fixture def document_elm(self): - return ( - a_document().with_nsdecls().with_child( - a_body().with_child( - a_p().with_child( - a_pPr().with_child( - a_sectPr()))).with_child( - a_sectPr())) - ).element + return element('w:document/w:body/(w:p/w:pPr/w:sectPr, w:sectPr)') From 668f0ed54e28d1db9d6a33071633f03e7089d995 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 29 Jun 2014 20:22:32 -0700 Subject: [PATCH 149/809] test: refactor tests.test_text to use cxml --- docx/enum/base.py | 10 +- docx/oxml/simpletypes.py | 11 + docx/oxml/text.py | 3 +- docx/text.py | 13 +- tests/test_enum.py | 10 +- tests/test_text.py | 667 +++++++++++++++++---------------------- 6 files changed, 322 insertions(+), 392 deletions(-) diff --git a/docx/enum/base.py b/docx/enum/base.py index f5218543e..aad44e9c8 100644 --- a/docx/enum/base.py +++ b/docx/enum/base.py @@ -159,12 +159,14 @@ class EnumerationBase(object): __ms_name__ = '' @classmethod - def is_valid_setting(cls, value): + def validate(cls, value): """ - Return |True| if *value* is an assignable value, |False| if it is - a return value-only member or not a member value. + Raise |ValueError| if *value* is not an assignable value. """ - return value in cls._valid_settings + if value not in cls._valid_settings: + raise ValueError( + "%s not a member of %s enumeration" % (value, cls.__name__) + ) Enumeration = MetaEnumeration( diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 9a621e399..584844a48 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -8,6 +8,7 @@ from __future__ import absolute_import, print_function +from ..exceptions import InvalidXmlError from ..shared import Emu, Twips @@ -95,6 +96,11 @@ class XsdBoolean(BaseSimpleType): @classmethod def convert_from_xml(cls, str_value): + 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 + ) return str_value in ('1', 'true') @classmethod @@ -216,6 +222,11 @@ class ST_OnOff(XsdBoolean): @classmethod def convert_from_xml(cls, str_value): + 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') diff --git a/docx/oxml/text.py b/docx/oxml/text.py index 46bf1d54c..ee81ee02e 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -237,7 +237,8 @@ def text(self): text = '' for child in self: if child.tag == qn('w:t'): - text += child.text + 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')): diff --git a/docx/text.py b/docx/text.py index 69adf1c21..a221fde99 100644 --- a/docx/text.py +++ b/docx/text.py @@ -6,7 +6,7 @@ from __future__ import absolute_import, print_function, unicode_literals -from docx.enum.text import WD_BREAK, WD_UNDERLINE +from docx.enum.text import WD_BREAK def boolproperty(f): @@ -39,13 +39,17 @@ def getter(obj): return prop_value.val def setter(obj, value): + if value not in (True, False, None): + raise ValueError( + "assigned value must be True, False, or None, got '%s'" + % value + ) r, attr_name = obj._r, f(obj) rPr = r.get_or_add_rPr() _remove_prop(rPr, attr_name) if value is not None: elm = _add_prop(rPr, attr_name) - if bool(value) is False: - elm.val = False + elm.val = value return property(getter, setter, doc=f.__doc__) @@ -432,9 +436,6 @@ def underline(self): @underline.setter def underline(self, value): - if not WD_UNDERLINE.is_valid_setting(value): - tmpl = "'%s' is not a valid setting for Run.underline" - raise ValueError(tmpl % value) self._r.underline = value @boolproperty diff --git a/tests/test_enum.py b/tests/test_enum.py index 5cf2371cc..edfe595dc 100644 --- a/tests/test_enum.py +++ b/tests/test_enum.py @@ -67,10 +67,12 @@ def it_provides_the_enumeration_value_for_each_named_member(self): assert FOOBAR.READ_ONLY == -2 def it_knows_if_a_setting_is_valid(self): - assert FOOBAR.is_valid_setting(None) - assert FOOBAR.is_valid_setting(FOOBAR.READ_WRITE) - assert not FOOBAR.is_valid_setting('foobar') - assert not FOOBAR.is_valid_setting(FOOBAR.READ_ONLY) + FOOBAR.validate(None) + FOOBAR.validate(FOOBAR.READ_WRITE) + with pytest.raises(ValueError): + FOOBAR.validate('foobar') + with pytest.raises(ValueError): + FOOBAR.validate(FOOBAR.READ_ONLY) def it_can_be_referred_to_by_a_convenience_alias_if_defined(self): assert BARFOO is FOOBAR # noqa diff --git a/tests/test_text.py b/tests/test_text.py index f1a07ab99..ec80d37e3 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -9,27 +9,19 @@ ) from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_UNDERLINE -from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.oxml.text import CT_P, CT_R from docx.text import Paragraph, Run import pytest -from .oxml.parts.unitdata.document import a_body -from .oxml.unitdata.text import ( - a_b, a_bCs, a_br, a_caps, a_cr, a_cs, a_dstrike, a_jc, a_p, a_pPr, - a_pStyle, a_shadow, a_smallCaps, a_snapToGrid, a_specVanish, a_strike, - a_t, a_tab, a_u, a_vanish, a_webHidden, an_emboss, an_i, an_iCs, - an_imprint, an_oMath, a_noProof, an_outline, an_r, an_rPr, an_rStyle, - an_rtl -) -from .unitutil.mock import call, class_mock, instance_mock, Mock +from .unitutil.cxml import element, xml +from .unitutil.mock import call, class_mock, instance_mock class DescribeParagraph(object): - def it_has_a_sequence_of_the_runs_it_contains(self, runs_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_), call(r_2_)] @@ -51,25 +43,14 @@ def it_can_change_its_alignment_value(self, alignment_set_fixture): paragraph.alignment = value assert paragraph._p.xml == expected_xml - def it_knows_its_paragraph_style(self): - cases = ( - (Mock(name='p_elm', style='foobar'), 'foobar'), - (Mock(name='p_elm', style=None), 'Normal'), - ) - for p_elm, expected_style in cases: - p = Paragraph(p_elm) - assert p.style == expected_style - - def it_can_set_its_paragraph_style(self): - cases = ( - ('foobar', 'foobar'), - ('Normal', None), - ) - for style, expected_setting in cases: - p_elm = Mock(name='p_elm') - p = Paragraph(p_elm) - p.style = style - assert p_elm.style == expected_setting + def it_knows_its_paragraph_style(self, style_get_fixture): + paragraph, expected_style = style_get_fixture + assert paragraph.style == expected_style + + def it_can_change_its_paragraph_style(self, style_set_fixture): + paragraph, value, expected_xml = style_set_fixture + paragraph.style = value + assert paragraph._p.xml == expected_xml def it_knows_the_text_it_contains(self, text_get_fixture): paragraph, expected_text = text_get_fixture @@ -98,94 +79,66 @@ def it_can_remove_its_content_while_preserving_formatting( # fixtures ------------------------------------------------------- @pytest.fixture(params=[ - (None, None), (None, 'Strong'), ('foobar', None), ('foobar', 'Strong') + ('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:rPr/w:rStyle{w:val=Strong}'), + ('w:p', 'foobar', 'Strong', + 'w:p/w:r/(w:rPr/w:rStyle{w:val=Strong}, w:t"foobar")'), ]) - def add_run_fixture(self, request, paragraph): - text, style = request.param - r_bldr = an_r() - if style: - r_bldr.with_child( - an_rPr().with_child(an_rStyle().with_val(style)) - ) - if text: - r_bldr.with_child(a_t().with_text(text)) - expected_xml = a_p().with_nsdecls().with_child(r_bldr).xml() + def add_run_fixture(self, request): + before_cxml, text, style, after_cxml = request.param + paragraph = Paragraph(element(before_cxml)) + expected_xml = xml(after_cxml) return paragraph, text, style, expected_xml @pytest.fixture(params=[ - ('center', WD_ALIGN_PARAGRAPH.CENTER), - (None, None), + ('w:p/w:pPr/w:jc{w:val=center}', WD_ALIGN_PARAGRAPH.CENTER), + ('w:p', None), ]) def alignment_get_fixture(self, request): - jc_val, expected_alignment_value = request.param - p_bldr = a_p().with_nsdecls() - if jc_val is not None: - p_bldr.with_child(a_pPr().with_child(a_jc().with_val(jc_val))) - p = p_bldr.element - paragraph = Paragraph(p) + cxml, expected_alignment_value = request.param + paragraph = Paragraph(element(cxml)) return paragraph, expected_alignment_value @pytest.fixture(params=[ - ('left', WD_ALIGN_PARAGRAPH.CENTER, 'center'), - ('left', None, None), - (None, WD_ALIGN_PARAGRAPH.LEFT, 'left'), - (None, None, None), + ('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_jc_val, new_alignment_value, expected_jc_val = request.param - # paragraph -------------------- - p_bldr = a_p().with_nsdecls() - if initial_jc_val is not None: - p_bldr.with_child( - a_pPr().with_child( - a_jc().with_val(initial_jc_val)) - ) - p = p_bldr.element - paragraph = Paragraph(p) - # expected_xml ----------------- - pPr_bldr = a_pPr() - if expected_jc_val is not None: - pPr_bldr.with_child(a_jc().with_val(expected_jc_val)) - p_bldr = a_p().with_nsdecls().with_child(pPr_bldr) - expected_xml = p_bldr.xml() + initial_cxml, new_alignment_value, expected_cxml = request.param + paragraph = Paragraph(element(initial_cxml)) + expected_xml = xml(expected_cxml) return paragraph, new_alignment_value, expected_xml - @pytest.fixture + @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): - """ - After XML should be before XML with content removed. So snapshot XML - after adding formatting but before adding content to get after XML. - """ - style, text = ('Heading1', 'foo\tbar') - p = OxmlElement('w:p') - # expected_xml ----------------- - if style is not None: - p.style = style - expected_xml = p.xml - # paragraph -------------------- - paragraph = Paragraph(p) - if text is not None: - paragraph.add_run(text) + initial_cxml, expected_cxml = request.param + paragraph = Paragraph(element(initial_cxml)) + expected_xml = xml(expected_cxml) return paragraph, expected_xml - @pytest.fixture - def insert_before_fixture(self): - text, style = 'foobar', 'Heading1' - body = ( - a_body().with_nsdecls().with_child( - a_p()) - ).element - p = body.find(qn('w:p')) - paragraph = Paragraph(p) - expected_xml = ( - a_body().with_nsdecls().with_child( - a_p().with_child( - a_pPr().with_child( - a_pStyle().with_val(style))).with_child( - an_r().with_child( - a_t().with_text(text)))).with_child( - a_p()) - ).xml() + @pytest.fixture(params=[ + ('w:body/w:p', 'foobar', 'Heading1', + 'w:body/(w:p/(w:pPr/w:pStyle{w:val=Heading1},w:r/w:t"foobar"),w:p)') + ]) + def insert_before_fixture(self, request): + body_cxml, text, style, expected_cxml = request.param + body = element(body_cxml) + paragraph = Paragraph(body.find(qn('w:p'))) + expected_xml = xml(expected_cxml) return paragraph, text, style, body, expected_xml @pytest.fixture @@ -194,24 +147,57 @@ 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 - def text_get_fixture(self): - p = ( - a_p().with_nsdecls().with_child( - an_r().with_child( - a_t().with_text('foo'))).with_child( - an_r().with_child( - a_t().with_text(' de bar'))) - ).element - paragraph = Paragraph(p) - return paragraph, 'foo de bar' + @pytest.fixture(params=[ + ('w:p', 'Normal'), + ('w:p/w:pPr', 'Normal'), + ('w:p/w:pPr/w:pStyle{w:val=Heading1}', 'Heading1'), + ]) + def style_get_fixture(self, request): + p_cxml, expected_style = request.param + paragraph = Paragraph(element(p_cxml)) + return paragraph, expected_style + + @pytest.fixture(params=[ + ('w:p', 'Heading1', + 'w:p/w:pPr/w:pStyle{w:val=Heading1}'), + ('w:p/w:pPr', 'Heading1', + 'w:p/w:pPr/w:pStyle{w:val=Heading1}'), + ('w:p/w:pPr/w:pStyle{w:val=Heading1}', 'Heading2', + 'w:p/w:pPr/w:pStyle{w:val=Heading2}'), + ('w:p/w:pPr/w:pStyle{w:val=Heading1}', None, + 'w:p/w:pPr'), + ('w:p', None, + 'w:p/w:pPr'), + ]) + def style_set_fixture(self, request): + p_cxml, new_style_value, expected_cxml = request.param + paragraph = Paragraph(element(p_cxml)) + expected_xml = xml(expected_cxml) + return paragraph, new_style_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'), + ]) + def text_get_fixture(self, request): + p_cxml, expected_text_value = request.param + paragraph = Paragraph(element(p_cxml)) + return paragraph, expected_text_value @pytest.fixture def text_set_fixture(self): - p = a_p().with_nsdecls().element - paragraph = Paragraph(p) - paragraph.add_run('barfoo') - return paragraph, 'foo\tbar\rbaz\n', 'foo\tbar\nbaz\n' + paragraph = Paragraph(element('w:p')) + 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 --------------------------------------------- @@ -219,11 +205,6 @@ def text_set_fixture(self): def p_(self, request, r_, r_2_): return instance_mock(request, CT_P, r_lst=(r_, r_2_)) - @pytest.fixture - def paragraph(self): - p = a_p().with_nsdecls().element - return Paragraph(p) - @pytest.fixture def Run_(self, request, runs_): run_, run_2_ = runs_ @@ -297,8 +278,7 @@ def it_can_add_a_tab(self, add_tab_fixture): run.add_tab() assert run._r.xml == expected_xml - def it_can_remove_its_content_while_preserving_formatting( - self, clear_fixture): + def it_can_remove_its_content_but_keep_formatting(self, clear_fixture): run, expected_xml = clear_fixture _run = run.clear() assert run._r.xml == expected_xml @@ -316,303 +296,236 @@ def it_can_replace_the_text_it_contains(self, text_set_fixture): # fixtures ------------------------------------------------------- @pytest.fixture(params=[ - 'line', 'page', 'column', 'clr_lt', 'clr_rt', 'clr_all' + (WD_BREAK.LINE, 'w:r/w:br'), + (WD_BREAK.PAGE, 'w:r/w:br{w:type=page}'), + (WD_BREAK.COLUMN, 'w:r/w:br{w:type=column}'), + (WD_BREAK.LINE_CLEAR_LEFT, + 'w:r/w:br{w:type=textWrapping, w:clear=left}'), + (WD_BREAK.LINE_CLEAR_RIGHT, + 'w:r/w:br{w:type=textWrapping, w:clear=right}'), + (WD_BREAK.LINE_CLEAR_ALL, + 'w:r/w:br{w:type=textWrapping, w:clear=all}'), ]) - def add_break_fixture(self, request, run): - type_, clear, break_type = { - 'line': (None, None, WD_BREAK.LINE), - 'page': ('page', None, WD_BREAK.PAGE), - 'column': ('column', None, WD_BREAK.COLUMN), - 'clr_lt': ('textWrapping', 'left', WD_BREAK.LINE_CLEAR_LEFT), - 'clr_rt': ('textWrapping', 'right', WD_BREAK.LINE_CLEAR_RIGHT), - 'clr_all': ('textWrapping', 'all', WD_BREAK.LINE_CLEAR_ALL), - }[request.param] - # expected_xml ----------------- - br_bldr = a_br() - if type_ is not None: - br_bldr.with_type(type_) - if clear is not None: - br_bldr.with_clear(clear) - expected_xml = an_r().with_nsdecls().with_child(br_bldr).xml() + def add_break_fixture(self, request): + break_type, expected_cxml = request.param + run = Run(element('w:r')) + expected_xml = xml(expected_cxml) return run, break_type, expected_xml - @pytest.fixture - def add_tab_fixture(self, run): - expected_xml = an_r().with_nsdecls().with_child(a_tab()).xml() + @pytest.fixture(params=[ + ('w:r/w:t"foo"', 'w:r/(w:t"foo", w:tab)'), + ]) + def add_tab_fixture(self, request): + r_cxml, expected_cxml = request.param + run = Run(element(r_cxml)) + expected_xml = xml(expected_cxml) return run, expected_xml - @pytest.fixture(params=['foobar', ' foo bar', 'bar foo ']) - def add_text_fixture(self, request, run, Text_): - text_str = request.param - t_bldr = a_t().with_text(text_str) - if text_str.startswith(' ') or text_str.endswith(' '): - t_bldr.with_space('preserve') - expected_xml = an_r().with_nsdecls().with_child(t_bldr).xml() - return run, text_str, expected_xml, Text_ + @pytest.fixture(params=[ + ('w:r', 'foo', 'w:r/w:t"foo"'), + ('w:r/w:t"foo"', 'bar', 'w:r/(w:t"foo", w:t"bar")'), + ('w:r', 'fo ', 'w:r/w:t{xml:space=preserve}"fo "'), + ('w:r', 'f o', 'w:r/w:t"f o"'), + ]) + def add_text_fixture(self, request, Text_): + r_cxml, text, expected_cxml = request.param + run = Run(element(r_cxml)) + expected_xml = xml(expected_cxml) + return run, text, expected_xml, Text_ @pytest.fixture(params=[ - ('all_caps', True), ('all_caps', False), ('all_caps', None), - ('bold', True), ('bold', False), ('bold', None), - ('italic', True), ('italic', False), ('italic', None), - ('complex_script', True), ('complex_script', False), - ('complex_script', None), - ('cs_bold', True), ('cs_bold', False), ('cs_bold', None), - ('cs_italic', True), ('cs_italic', False), ('cs_italic', None), - ('double_strike', True), ('double_strike', False), - ('double_strike', None), - ('emboss', True), ('emboss', False), ('emboss', None), - ('hidden', True), ('hidden', False), ('hidden', None), - ('italic', True), ('italic', False), ('italic', None), - ('imprint', True), ('imprint', False), ('imprint', None), - ('math', True), ('math', False), ('math', None), - ('no_proof', True), ('no_proof', False), ('no_proof', None), - ('outline', True), ('outline', False), ('outline', None), - ('rtl', True), ('rtl', False), ('rtl', None), - ('shadow', True), ('shadow', False), ('shadow', None), - ('small_caps', True), ('small_caps', False), ('small_caps', None), - ('snap_to_grid', True), ('snap_to_grid', False), - ('snap_to_grid', None), - ('spec_vanish', True), ('spec_vanish', False), ('spec_vanish', None), - ('strike', True), ('strike', False), ('strike', None), - ('web_hidden', True), ('web_hidden', False), ('web_hidden', None), + ('w:r/w:rPr', 'all_caps', None), + ('w:r/w:rPr/w:caps', 'all_caps', True), + ('w:r/w:rPr/w:caps{w:val=on}', 'all_caps', True), + ('w:r/w:rPr/w:caps{w:val=off}', 'all_caps', False), + ('w:r/w:rPr/w:b{w:val=1}', 'bold', True), + ('w:r/w:rPr/w:i{w:val=0}', 'italic', False), + ('w:r/w:rPr/w:cs{w:val=true}', 'complex_script', True), + ('w:r/w:rPr/w:bCs{w:val=false}', 'cs_bold', False), + ('w:r/w:rPr/w:iCs{w:val=on}', 'cs_italic', True), + ('w:r/w:rPr/w:dstrike{w:val=off}', 'double_strike', False), + ('w:r/w:rPr/w:emboss{w:val=1}', 'emboss', True), + ('w:r/w:rPr/w:vanish{w:val=0}', 'hidden', False), + ('w:r/w:rPr/w:i{w:val=true}', 'italic', True), + ('w:r/w:rPr/w:imprint{w:val=false}', 'imprint', False), + ('w:r/w:rPr/w:oMath{w:val=on}', 'math', True), + ('w:r/w:rPr/w:noProof{w:val=off}', 'no_proof', False), + ('w:r/w:rPr/w:outline{w:val=1}', 'outline', True), + ('w:r/w:rPr/w:rtl{w:val=0}', 'rtl', False), + ('w:r/w:rPr/w:shadow{w:val=true}', 'shadow', True), + ('w:r/w:rPr/w:smallCaps{w:val=false}', 'small_caps', False), + ('w:r/w:rPr/w:snapToGrid{w:val=on}', 'snap_to_grid', True), + ('w:r/w:rPr/w:specVanish{w:val=off}', 'spec_vanish', False), + ('w:r/w:rPr/w:strike{w:val=1}', 'strike', True), + ('w:r/w:rPr/w:webHidden{w:val=0}', 'web_hidden', False), ]) def bool_prop_get_fixture(self, request): - bool_prop_name, expected_state = request.param - bool_prop_bldr = { - 'all_caps': a_caps, - 'bold': a_b, - 'complex_script': a_cs, - 'cs_bold': a_bCs, - 'cs_italic': an_iCs, - 'double_strike': a_dstrike, - 'emboss': an_emboss, - 'hidden': a_vanish, - 'italic': an_i, - 'imprint': an_imprint, - 'math': an_oMath, - 'no_proof': a_noProof, - 'outline': an_outline, - 'rtl': an_rtl, - 'shadow': a_shadow, - 'small_caps': a_smallCaps, - 'snap_to_grid': a_snapToGrid, - 'spec_vanish': a_specVanish, - 'strike': a_strike, - 'web_hidden': a_webHidden, - }[bool_prop_name] - r_bldr = an_r().with_nsdecls() - if expected_state is not None: - child_bldr = bool_prop_bldr() - if expected_state is False: - child_bldr.with_val('off') - rPr_bldr = an_rPr().with_child(child_bldr) - r_bldr.with_child(rPr_bldr) - r = r_bldr.element - run = Run(r) - return run, bool_prop_name, expected_state + r_cxml, bool_prop_name, expected_value = request.param + run = Run(element(r_cxml)) + return run, bool_prop_name, expected_value @pytest.fixture(params=[ - ('all_caps', True), ('all_caps', False), ('all_caps', None), - ('bold', True), ('bold', False), ('bold', None), - ('italic', True), ('italic', False), ('italic', None), - ('complex_script', True), ('complex_script', False), - ('complex_script', None), - ('cs_bold', True), ('cs_bold', False), ('cs_bold', None), - ('cs_italic', True), ('cs_italic', False), ('cs_italic', None), - ('double_strike', True), ('double_strike', False), - ('double_strike', None), - ('emboss', True), ('emboss', False), ('emboss', None), - ('hidden', True), ('hidden', False), ('hidden', None), - ('italic', True), ('italic', False), ('italic', None), - ('imprint', True), ('imprint', False), ('imprint', None), - ('math', True), ('math', False), ('math', None), - ('no_proof', True), ('no_proof', False), ('no_proof', None), - ('outline', True), ('outline', False), ('outline', None), - ('rtl', True), ('rtl', False), ('rtl', None), - ('shadow', True), ('shadow', False), ('shadow', None), - ('small_caps', True), ('small_caps', False), ('small_caps', None), - ('snap_to_grid', True), ('snap_to_grid', False), - ('snap_to_grid', None), - ('spec_vanish', True), ('spec_vanish', False), ('spec_vanish', None), - ('strike', True), ('strike', False), ('strike', None), - ('web_hidden', True), ('web_hidden', False), ('web_hidden', None), + # nothing to True, False, and None --------------------------- + ('w:r', 'all_caps', True, + 'w:r/w:rPr/w:caps'), + ('w:r', 'bold', False, + 'w:r/w:rPr/w:b{w:val=0}'), + ('w:r', 'italic', None, + 'w:r/w:rPr'), + # default to True, False, and None --------------------------- + ('w:r/w:rPr/w:cs', 'complex_script', True, + 'w:r/w:rPr/w:cs'), + ('w:r/w:rPr/w:bCs', 'cs_bold', False, + 'w:r/w:rPr/w:bCs{w:val=0}'), + ('w:r/w:rPr/w:iCs', 'cs_italic', None, + 'w:r/w:rPr'), + # True to True, False, and None ------------------------------ + ('w:r/w:rPr/w:dstrike{w:val=1}', 'double_strike', True, + 'w:r/w:rPr/w:dstrike'), + ('w:r/w:rPr/w:emboss{w:val=on}', 'emboss', False, + 'w:r/w:rPr/w:emboss{w:val=0}'), + ('w:r/w:rPr/w:vanish{w:val=1}', 'hidden', None, + 'w:r/w:rPr'), + # False to True, False, and None ----------------------------- + ('w:r/w:rPr/w:i{w:val=false}', 'italic', True, + 'w:r/w:rPr/w:i'), + ('w:r/w:rPr/w:imprint{w:val=0}', 'imprint', False, + 'w:r/w:rPr/w:imprint{w:val=0}'), + ('w:r/w:rPr/w:oMath{w:val=off}', 'math', None, + 'w:r/w:rPr'), + # random mix ------------------------------------------------- + ('w:r/w:rPr/w:noProof{w:val=1}', 'no_proof', False, + 'w:r/w:rPr/w:noProof{w:val=0}'), + ('w:r/w:rPr', 'outline', True, + 'w:r/w:rPr/w:outline'), + ('w:r/w:rPr/w:rtl{w:val=true}', 'rtl', False, + 'w:r/w:rPr/w:rtl{w:val=0}'), + ('w:r/w:rPr/w:shadow{w:val=on}', 'shadow', True, + 'w:r/w:rPr/w:shadow'), + ('w:r/w:rPr/w:smallCaps', 'small_caps', False, + 'w:r/w:rPr/w:smallCaps{w:val=0}'), + ('w:r/w:rPr/w:snapToGrid', 'snap_to_grid', True, + 'w:r/w:rPr/w:snapToGrid'), + ('w:r/w:rPr/w:specVanish', 'spec_vanish', None, + 'w:r/w:rPr'), + ('w:r/w:rPr/w:strike{w:val=foo}', 'strike', True, + 'w:r/w:rPr/w:strike'), + ('w:r/w:rPr/w:webHidden', 'web_hidden', False, + 'w:r/w:rPr/w:webHidden{w:val=0}'), ]) def bool_prop_set_fixture(self, request): - bool_prop_name, value = request.param - bool_prop_bldr = { - 'all_caps': a_caps, - 'bold': a_b, - 'complex_script': a_cs, - 'cs_bold': a_bCs, - 'cs_italic': an_iCs, - 'double_strike': a_dstrike, - 'emboss': an_emboss, - 'hidden': a_vanish, - 'italic': an_i, - 'imprint': an_imprint, - 'math': an_oMath, - 'no_proof': a_noProof, - 'outline': an_outline, - 'rtl': an_rtl, - 'shadow': a_shadow, - 'small_caps': a_smallCaps, - 'snap_to_grid': a_snapToGrid, - 'spec_vanish': a_specVanish, - 'strike': a_strike, - 'web_hidden': a_webHidden, - }[bool_prop_name] - # run -------------------------- - r = an_r().with_nsdecls().element - run = Run(r) - # expected_xml ----------------- - rPr_bldr = an_rPr() - if value is not None: - child_bldr = bool_prop_bldr() - if value is False: - child_bldr.with_val(0) - rPr_bldr.with_child(child_bldr) - expected_xml = an_r().with_nsdecls().with_child(rPr_bldr).xml() + initial_r_cxml, bool_prop_name, value, expected_cxml = request.param + run = Run(element(initial_r_cxml)) + expected_xml = xml(expected_cxml) return run, bool_prop_name, value, expected_xml @pytest.fixture(params=[ - ('bi', 'foobar'), ('bi', None), ('', 'foobar'), ('', None) + ('w:r', 'w:r'), + ('w:r/w:t"foo"', 'w:r'), + ('w:r/w:br', 'w:r'), + ('w:r/w:rPr', 'w:r/w:rPr'), + ('w:r/(w:rPr, w:t"foo")', 'w:r/w:rPr'), + ('w:r/(w:rPr/(w:b, w:i), w:t"foo", w:cr, w:t"bar")', + 'w:r/w:rPr/(w:b, w:i)'), ]) def clear_fixture(self, request): - formatting, text = request.param - r = OxmlElement('w:r') - if 'b' in formatting: - r.get_or_add_rPr()._add_b().val = True - if 'i' in formatting: - r.get_or_add_rPr()._add_i().val = True - expected_xml = r.xml - if text is not None: - r.add_t(text) - run = Run(r) + initial_r_cxml, expected_cxml = request.param + run = Run(element(initial_r_cxml)) + expected_xml = xml(expected_cxml) return run, expected_xml - @pytest.fixture(params=['Foobar', None]) + @pytest.fixture(params=[ + ('w:r', None), + ('w:r/w:rPr/w:rStyle{w:val=Foobar}', 'Foobar'), + ]) def style_get_fixture(self, request): - style = request.param - r = self.r_bldr_with_style(style).element - run = Run(r) - return run, style + r_cxml, expected_style = request.param + run = Run(element(r_cxml)) + return run, expected_style @pytest.fixture(params=[ - (None, None), - (None, 'Foobar'), - ('Foobar', None), - ('Foobar', 'Foobar'), - ('Foobar', 'Barfoo'), + ('w:r', None, + 'w:r/w:rPr'), + ('w:r', 'Foo', + 'w:r/w:rPr/w:rStyle{w:val=Foo}'), + ('w:r/w:rPr/w:rStyle{w:val=Foo}', None, + 'w:r/w:rPr'), + ('w:r/w:rPr/w:rStyle{w:val=Foo}', 'Bar', + 'w:r/w:rPr/w:rStyle{w:val=Bar}'), ]) def style_set_fixture(self, request): - before_style, after_style = request.param - r = self.r_bldr_with_style(before_style).element - run = Run(r) - expected_xml = self.r_bldr_with_style(after_style).xml() - return run, after_style, expected_xml + initial_r_cxml, new_style, expected_cxml = request.param + run = Run(element(initial_r_cxml)) + expected_xml = xml(expected_cxml) + return run, new_style, expected_xml @pytest.fixture(params=[ - (('',), ''), - (('xfoobar',), 'foobar'), - (('bpage', 'xabc', 'xdef', 't'), '\nabcdef\t'), - (('xabc', 't', 'xdef', 'n'), 'abc\tdef\n'), + ('w:r', ''), + ('w:r/w:t"foobar"', 'foobar'), + ('w:r/(w:t"abc", w:tab, w:t"def", w:cr)', 'abc\tdef\n'), + ('w:r/(w:br{w:type=page}, w:t"abc", w:t"def", w:tab)', '\nabcdef\t'), ]) def text_get_fixture(self, request): - content_children, expected_text = request.param - r_bldr = self.r_content_bldr(content_children) - run = Run(r_bldr.element) + r_cxml, expected_text = request.param + run = Run(element(r_cxml)) return run, expected_text @pytest.fixture(params=[ - ('abc', ('xabc',)), - ('abc\tdef', ('xabc', 't', 'xdef')), - ('abc\rdef', ('xabc', 'n', 'xdef')), + ('abc def', 'w:r/w:t"abc def"'), + ('abc\tdef', 'w:r/(w:t"abc", w:tab, w:t"def")'), + ('abc\ndef', 'w:r/(w:t"abc", w:cr, w:t"def")'), + ('abc\rdef', 'w:r/(w:t"abc", w:cr, w:t"def")'), ]) def text_set_fixture(self, request): - text, expected_elm_codes = request.param - # starting run contains text, so can tell if it doesn't get replaced - r_bldr = self.r_content_bldr(('xfoobar')) - run = Run(r_bldr.element) - # expected_xml ----------------- - r_bldr = self.r_content_bldr(expected_elm_codes) - expected_xml = r_bldr.xml() - return run, text, expected_xml + new_text, expected_cxml = request.param + initial_r_cxml = 'w:r/w:t"should get deleted"' + run = Run(element(initial_r_cxml)) + expected_xml = xml(expected_cxml) + return run, new_text, expected_xml @pytest.fixture(params=[ - (None, None), - ('single', True), - ('none', False), - ('double', WD_UNDERLINE.DOUBLE), + ('w:r', None), + ('w:r/w:rPr/w:u', None), + ('w:r/w:rPr/w:u{w:val=single}', True), + ('w:r/w:rPr/w:u{w:val=none}', False), + ('w:r/w:rPr/w:u{w:val=double}', WD_UNDERLINE.DOUBLE), + ('w:r/w:rPr/w:u{w:val=wave}', WD_UNDERLINE.WAVY), ]) def underline_get_fixture(self, request): - underline_type, expected_prop_value = request.param - r = self.r_bldr_with_underline(underline_type).element - run = Run(r) - return run, expected_prop_value - - @pytest.fixture(params=['foobar', 42, 'single']) - def underline_raise_fixture(self, request): - underline = request.param - r = self.r_bldr_with_underline(None).element - run = Run(r) - return run, underline + r_cxml, expected_underline = request.param + run = Run(element(r_cxml)) + return run, expected_underline @pytest.fixture(params=[ - (None, True, 'single'), - (None, False, 'none'), - (None, None, None), - (None, WD_UNDERLINE.SINGLE, 'single'), - (None, WD_UNDERLINE.WAVY, 'wave'), - ('single', True, 'single'), - ('single', False, 'none'), - ('single', None, None), - ('single', WD_UNDERLINE.SINGLE, 'single'), - ('single', WD_UNDERLINE.DOTTED, 'dotted'), + ('w:r', True, 'w:r/w:rPr/w:u{w:val=single}'), + ('w:r', False, 'w:r/w:rPr/w:u{w:val=none}'), + ('w:r', None, 'w:r/w:rPr'), + ('w:r', WD_UNDERLINE.SINGLE, 'w:r/w:rPr/w:u{w:val=single}'), + ('w:r', WD_UNDERLINE.THICK, 'w:r/w:rPr/w:u{w:val=thick}'), + ('w:r/w:rPr/w:u{w:val=single}', True, + 'w:r/w:rPr/w:u{w:val=single}'), + ('w:r/w:rPr/w:u{w:val=single}', False, + 'w:r/w:rPr/w:u{w:val=none}'), + ('w:r/w:rPr/w:u{w:val=single}', None, + 'w:r/w:rPr'), + ('w:r/w:rPr/w:u{w:val=single}', WD_UNDERLINE.SINGLE, + 'w:r/w:rPr/w:u{w:val=single}'), + ('w:r/w:rPr/w:u{w:val=single}', WD_UNDERLINE.DOTTED, + 'w:r/w:rPr/w:u{w:val=dotted}'), ]) def underline_set_fixture(self, request): - before_val, underline, expected_val = request.param - r = self.r_bldr_with_underline(before_val).element - run = Run(r) - expected_xml = self.r_bldr_with_underline(expected_val).xml() - return run, underline, expected_xml - - # fixture components --------------------------------------------- + initial_r_cxml, new_underline, expected_cxml = request.param + run = Run(element(initial_r_cxml)) + expected_xml = xml(expected_cxml) + return run, new_underline, expected_xml - def r_bldr_with_style(self, style): - rPr_bldr = an_rPr() - if style is not None: - rPr_bldr.with_child(an_rStyle().with_val(style)) - r_bldr = an_r().with_nsdecls().with_child(rPr_bldr) - return r_bldr - - def r_bldr_with_underline(self, underline_type): - rPr_bldr = an_rPr() - if underline_type is not None: - rPr_bldr.with_child(a_u().with_val(underline_type)) - r_bldr = an_r().with_nsdecls().with_child(rPr_bldr) - return r_bldr - - def r_content_bldr(self, elm_codes): - """ - Return a ```` builder having child elements indicated by - *elm_codes*. - """ - r_bldr = an_r().with_nsdecls() - for e in elm_codes: - if e.startswith('x'): - r_bldr.with_child(a_t().with_text(e[1:])) - elif e == 't': - r_bldr.with_child(a_tab()) - elif e.startswith('b'): - r_bldr.with_child(a_br().with_type(e[1:])) - elif e == 'n': - r_bldr.with_child(a_cr()) - return r_bldr + @pytest.fixture(params=['foobar', 42, 'single']) + def underline_raise_fixture(self, request): + invalid_underline_setting = request.param + run = Run(element('w:r/w:rPr')) + return run, invalid_underline_setting - @pytest.fixture - def run(self): - r = an_r().with_nsdecls().element - return Run(r) + # fixture components --------------------------------------------- @pytest.fixture def Text_(self, request): From 574fea1f0eca9cac3e164c0c207a6a807e4868ac Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 29 Jun 2014 23:26:21 -0700 Subject: [PATCH 150/809] test: refactor tests.test_section to use cxml --- tests/test_section.py | 117 +++++++++++++----------------------------- 1 file changed, 35 insertions(+), 82 deletions(-) diff --git a/tests/test_section.py b/tests/test_section.py index afc8cc756..018d51602 100644 --- a/tests/test_section.py +++ b/tests/test_section.py @@ -12,7 +12,6 @@ from docx.section import Section from docx.shared import Inches -from .oxml.unitdata.section import a_pgMar, a_sectPr from .unitutil.cxml import element, xml @@ -55,78 +54,58 @@ def it_can_change_its_orientation(self, orientation_set_fixture): assert section._sectPr.xml == expected_xml def it_knows_its_page_margins(self, margins_get_fixture): - section, left, right, top, bottom, gutter, header, footer = ( - margins_get_fixture - ) - assert section.left_margin == left - assert section.right_margin == right - assert section.top_margin == top - assert section.bottom_margin == bottom - assert section.gutter == gutter - assert section.header_distance == header - assert section.footer_distance == footer + section, margin_prop_name, expected_value = margins_get_fixture + value = getattr(section, margin_prop_name) + assert value == expected_value def it_can_change_its_page_margins(self, margins_set_fixture): section, margin_prop_name, new_value, expected_xml = ( margins_set_fixture ) - print(section._sectPr.xml) setattr(section, margin_prop_name, new_value) - print(section._sectPr.xml) assert section._sectPr.xml == expected_xml # fixtures ------------------------------------------------------- @pytest.fixture(params=[ - (True, 720, 720, 720, 720, 720, 720, 720), - (True, None, 360, None, 360, None, 360, None), - (False, None, None, None, None, None, None, None), + ('w:sectPr/w:pgMar{w:left=120}', 'left_margin', 76200), + ('w:sectPr/w:pgMar{w:right=240}', 'right_margin', 152400), + ('w:sectPr/w:pgMar{w:top=-360}', 'top_margin', -228600), + ('w:sectPr/w:pgMar{w:bottom=480}', 'bottom_margin', 304800), + ('w:sectPr/w:pgMar{w:gutter=600}', 'gutter', 381000), + ('w:sectPr/w:pgMar{w:header=720}', 'header_distance', 457200), + ('w:sectPr/w:pgMar{w:footer=840}', 'footer_distance', 533400), + ('w:sectPr/w:pgMar', 'left_margin', None), + ('w:sectPr', 'top_margin', None), ]) def margins_get_fixture(self, request): - (has_pgMar_child, left, right, top, bottom, gutter, header, - footer) = request.param - pgMar_bldr = self.pgMar_bldr(**{ - 'has_pgMar': has_pgMar_child, 'left': left, 'right': right, - 'top': top, 'bottom': bottom, 'gutter': gutter, 'header': header, - 'footer': footer - }) - sectPr = self.sectPr_bldr(pgMar_bldr).element - section = Section(sectPr) - expected_left = self.twips_to_emu(left) - expected_right = self.twips_to_emu(right) - expected_top = self.twips_to_emu(top) - expected_bottom = self.twips_to_emu(bottom) - expected_gutter = self.twips_to_emu(gutter) - expected_header = self.twips_to_emu(header) - expected_footer = self.twips_to_emu(footer) - return ( - section, expected_left, expected_right, expected_top, - expected_bottom, expected_gutter, expected_header, - expected_footer - ) + sectPr_cxml, margin_prop_name, expected_value = request.param + section = Section(element(sectPr_cxml)) + return section, margin_prop_name, expected_value @pytest.fixture(params=[ - ('left', 1440, 720), ('right', None, 1800), ('top', 2160, None), - ('bottom', 720, 2160), ('gutter', None, 360), ('header', 720, 630), - ('footer', None, 810) + ('w:sectPr', 'left_margin', Inches(1), + 'w:sectPr/w:pgMar{w:left=1440}'), + ('w:sectPr', 'right_margin', Inches(0.5), + 'w:sectPr/w:pgMar{w:right=720}'), + ('w:sectPr', 'top_margin', Inches(-0.25), + 'w:sectPr/w:pgMar{w:top=-360}'), + ('w:sectPr', 'bottom_margin', Inches(0.75), + 'w:sectPr/w:pgMar{w:bottom=1080}'), + ('w:sectPr', 'gutter', Inches(0.25), + 'w:sectPr/w:pgMar{w:gutter=360}'), + ('w:sectPr', 'header_distance', Inches(1.25), + 'w:sectPr/w:pgMar{w:header=1800}'), + ('w:sectPr', 'footer_distance', Inches(1.35), + 'w:sectPr/w:pgMar{w:footer=1944}'), + ('w:sectPr', 'left_margin', None, 'w:sectPr/w:pgMar'), + ('w:sectPr/w:pgMar{w:top=-360}', 'top_margin', Inches(0.6), + 'w:sectPr/w:pgMar{w:top=864}'), ]) def margins_set_fixture(self, request): - margin_side, initial_margin, new_margin = request.param - # section ---------------------- - pgMar_bldr = self.pgMar_bldr(**{margin_side: initial_margin}) - sectPr = self.sectPr_bldr(pgMar_bldr).element - section = Section(sectPr) - # property name ---------------- - property_name = { - 'left': 'left_margin', 'right': 'right_margin', - 'top': 'top_margin', 'bottom': 'bottom_margin', - 'gutter': 'gutter', 'header': 'header_distance', - 'footer': 'footer_distance' - }[margin_side] - # expected_xml ----------------- - pgMar_bldr = self.pgMar_bldr(**{margin_side: new_margin}) - expected_xml = self.sectPr_bldr(pgMar_bldr).xml() - new_value = self.twips_to_emu(new_margin) + sectPr_cxml, property_name, new_value, expected_cxml = request.param + section = Section(element(sectPr_cxml)) + expected_xml = xml(expected_cxml) return section, property_name, new_value, expected_xml @pytest.fixture(params=[ @@ -224,29 +203,3 @@ def start_type_set_fixture(self, request): section = Section(element(initial_cxml)) expected_xml = xml(expected_cxml) return section, new_start_type, expected_xml - - # fixture components --------------------------------------------- - - @staticmethod - def twips_to_emu(twips): - if twips is None: - return None - return twips * 635 - - def pgMar_bldr(self, **kwargs): - if kwargs.pop('has_pgMar', True) is False: - return None - pgMar_bldr = a_pgMar() - for key, value in kwargs.items(): - if value is None: - continue - set_attr_method = getattr(pgMar_bldr, 'with_%s' % key) - set_attr_method(value) - return pgMar_bldr - - def sectPr_bldr(self, *child_bldrs): - sectPr_bldr = a_sectPr().with_nsdecls() - for child_bldr in child_bldrs: - if child_bldr is not None: - sectPr_bldr.with_child(child_bldr) - return sectPr_bldr From 3c06e1ce50cc846a868c1ba9326137184021633b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 30 Jun 2014 00:00:35 -0700 Subject: [PATCH 151/809] test: refactor tests.test_shape to use cxml --- tests/test_shape.py | 63 +++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/tests/test_shape.py b/tests/test_shape.py index abe26780e..228768581 100644 --- a/tests/test_shape.py +++ b/tests/test_shape.py @@ -21,6 +21,7 @@ an_spPr, an_xfrm ) from .oxml.unitdata.text import an_r +from .unitutil.cxml import element, xml from .unitutil.mock import instance_mock @@ -58,37 +59,21 @@ def it_can_change_its_display_dimensions(self, dimensions_set_fixture): @pytest.fixture def dimensions_get_fixture(self): - cx, cy = 333, 666 - inline = self._inline_bldr_with_dimensions(cx, cy).element - inline_shape = InlineShape(inline) - return inline_shape, cx, cy + inline_cxml, expected_cx, expected_cy = ( + 'wp:inline/wp:extent{cx=333, cy=666}', 333, 666 + ) + inline_shape = InlineShape(element(inline_cxml)) + return inline_shape, expected_cx, expected_cy @pytest.fixture def dimensions_set_fixture(self): - # inline_shape ----------------- - cx, cy = 333, 666 - inline = self._inline_bldr_with_dimensions(cx, cy).element - inline_shape = InlineShape(inline) - # expected_xml ----------------- - cx, cy = cx + 111, cy + 222 - expected_xml = self._inline_bldr_with_dimensions(cx, cy).xml() - return inline_shape, cx, cy, expected_xml - - @pytest.fixture - def image_params(self): - filename = 'foobar.garf' - rId = 'rId42' - cx, cy = 914422, 223344 - return filename, rId, cx, cy - - @pytest.fixture - def image_part_(self, request, image_params): - filename, rId, cx, cy = image_params - image_part_ = instance_mock(request, ImagePart) - image_part_.default_cx = cx - image_part_.default_cy = cy - image_part_.filename = filename - return image_part_ + inline_cxml, new_cx, new_cy, expected_cxml = ( + 'wp:inline/wp:extent{cx=333, cy=666}', 444, 888, + 'wp:inline/wp:extent{cx=444, cy=888}' + ) + inline_shape = InlineShape(element(inline_cxml)) + expected_xml = xml(expected_cxml) + return inline_shape, new_cx, new_cy, expected_xml @pytest.fixture def new_picture_fixture(self, request, image_part_, image_params): @@ -144,11 +129,23 @@ def shape_type_fixture(self, request): return InlineShape(inline), shape_type - def _inline_bldr_with_dimensions(self, cx, cy): - return ( - an_inline().with_nsdecls().with_child( - an_extent().with_cx(cx).with_cy(cy)) - ) + # fixture components --------------------------------------------- + + @pytest.fixture + def image_params(self): + filename = 'foobar.garf' + rId = 'rId42' + cx, cy = 914422, 223344 + return filename, rId, cx, cy + + @pytest.fixture + def image_part_(self, request, image_params): + filename, rId, cx, cy = image_params + image_part_ = instance_mock(request, ImagePart) + image_part_.default_cx = cx + image_part_.default_cy = cy + image_part_.filename = filename + return image_part_ def _inline_with_picture(self, embed=False, link=False): picture_ns = nsmap['pic'] From 51c79c6272ca2d24047254e8e681fd55a36b97ed Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 30 Jun 2014 01:40:04 -0700 Subject: [PATCH 152/809] test: refactor tests.test_table to use cxml --- docx/oxml/table.py | 19 ++++- docx/table.py | 14 +--- tests/test_table.py | 192 ++++++++++++++++++-------------------------- 3 files changed, 97 insertions(+), 128 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 486f47b8b..38bde46ea 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -77,12 +77,23 @@ class CT_TblPr(BaseOxmlElement): """ tblStyle = ZeroOrOne('w:tblStyle') - def add_tblStyle(self, style_name): + @property + def style(self): """ - Return a new element having its style set to - *style_name*. + Return the value of the ``val`` attribute of the ```` + child or |None| if not present. """ - return self._add_tblStyle(val=style_name) + tblStyle = self.tblStyle + if tblStyle is None: + return None + return tblStyle.val + + @style.setter + def style(self, value): + self._remove_tblStyle() + if value is None: + return + self._add_tblStyle(val=value) class CT_Tc(BaseOxmlElement): diff --git a/docx/table.py b/docx/table.py index 2870583a2..a56524f83 100644 --- a/docx/table.py +++ b/docx/table.py @@ -67,18 +67,12 @@ def style(self): 'LightShading-Accent1'. Name is derived by removing spaces from the table style name displayed in the Word UI. """ - tblStyle = self._tblPr.tblStyle - if tblStyle is None: - return None - return tblStyle.val + return self._tblPr.style @style.setter - def style(self, style_name): - tblStyle = self._tblPr.tblStyle - if tblStyle is None: - self._tblPr.add_tblStyle(style_name) - else: - tblStyle.val = style_name + def style(self, value): + print('style value is %s' % value) + self._tblPr.style = value @property def _tblPr(self): diff --git a/tests/test_table.py b/tests/test_table.py index 1b9f64fc6..2f4ff6f07 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -13,10 +13,9 @@ ) from docx.text import Paragraph -from .oxml.unitdata.table import ( - a_gridCol, a_tbl, a_tblGrid, a_tblPr, a_tblStyle, a_tc, a_tcPr, a_tr -) -from .oxml.unitdata.text import a_p, a_t, an_r +from .oxml.unitdata.table import a_gridCol, a_tbl, a_tblGrid, a_tc, a_tr +from .oxml.unitdata.text import a_p +from .unitutil.cxml import element, xml class DescribeTable(object): @@ -52,8 +51,8 @@ def it_can_add_a_column(self, add_column_fixture): assert isinstance(column, _Column) assert column._gridCol is table._tbl.tblGrid.gridCol_lst[1] - def it_knows_its_table_style(self, table_style_fixture): - table, style = table_style_fixture + def it_knows_its_table_style(self, table_style_get_fixture): + table, style = table_style_get_fixture assert table.style == style def it_can_apply_a_table_style_by_name(self, table_style_set_fixture): @@ -77,94 +76,85 @@ def add_row_fixture(self): expected_xml = _tbl_bldr(rows=2, cols=2).xml() return table, expected_xml + @pytest.fixture(params=[ + ('w:tbl/w:tblPr', None), + ('w:tbl/w:tblPr/w:tblStyle{w:val=foobar}', 'foobar'), + ]) + def table_style_get_fixture(self, request): + tbl_cxml, expected_style = request.param + table = Table(element(tbl_cxml)) + return table, expected_style + + @pytest.fixture(params=[ + ('w:tbl/w:tblPr', 'foobar', + 'w:tbl/w:tblPr/w:tblStyle{w:val=foobar}'), + ('w:tbl/w:tblPr/w:tblStyle{w:val=foobar}', 'barfoo', + 'w:tbl/w:tblPr/w:tblStyle{w:val=barfoo}'), + ('w:tbl/w:tblPr/w:tblStyle{w:val=foobar}', None, + 'w:tbl/w:tblPr'), + ('w:tbl/w:tblPr', None, + 'w:tbl/w:tblPr'), + ]) + def table_style_set_fixture(self, request): + tbl_cxml, new_style, expected_cxml = request.param + table = Table(element(tbl_cxml)) + expected_xml = xml(expected_cxml) + return table, new_style, expected_xml + + # fixture components --------------------------------------------- + @pytest.fixture def table(self): tbl = _tbl_bldr(rows=2, cols=2).element table = Table(tbl) return table - @pytest.fixture - def table_style_fixture(self): - style = 'foobar' - tbl = ( - a_tbl().with_nsdecls().with_child( - a_tblPr().with_child( - a_tblStyle().with_val(style))) - ).element - table = Table(tbl) - return table, style - - @pytest.fixture - def table_style_set_fixture(self): - # table ------------------------ - tbl = a_tbl().with_nsdecls().with_child(a_tblPr()).element - table = Table(tbl) - # style_name ------------------- - style_name = 'foobar' - # expected_xml ----------------- - expected_xml = ( - a_tbl().with_nsdecls().with_child( - a_tblPr().with_child( - a_tblStyle().with_val(style_name))) - ).xml() - return table, style_name, expected_xml - class Describe_Cell(object): def it_provides_access_to_the_paragraphs_it_contains( - self, cell_with_paragraphs): - cell = cell_with_paragraphs + self, paragraphs_fixture): + cell = paragraphs_fixture paragraphs = cell.paragraphs assert len(paragraphs) == 2 - for p in paragraphs: - assert isinstance(p, Paragraph) + count = 0 + for idx, paragraph in enumerate(paragraphs): + assert isinstance(paragraph, Paragraph) + assert paragraph is paragraphs[idx] + count += 1 + assert count == 2 def it_can_replace_its_content_with_a_string_of_text( - self, cell_text_fixture): - cell, text, expected_xml = cell_text_fixture + self, text_set_fixture): + cell, text, expected_xml = text_set_fixture cell.text = text assert cell._tc.xml == expected_xml # fixtures ------------------------------------------------------- - @pytest.fixture - def cell_text_fixture(self): - # cell ------------------------- - tc = ( - a_tc().with_nsdecls().with_child( - a_tcPr()).with_child( - a_p()).with_child( - a_tbl()).with_child( - a_p()) - ).element - cell = _Cell(tc) - # text ------------------------- - text = 'foobar' - # expected_xml ----------------- - expected_xml = ( - a_tc().with_nsdecls().with_child( - a_tcPr()).with_child( - a_p().with_child( - an_r().with_child( - a_t().with_text(text)))) - ).xml() - return cell, text, expected_xml + @pytest.fixture(params=[ + ('w:tc/w:p', 'foobar', + 'w:tc/w:p/w:r/w:t"foobar"'), + ('w:tc/w:p', 'fo\tob\rar\n', + 'w:tc/w:p/w:r/(w:t"fo",w:tab,w:t"ob",w:cr,w:t"ar",w:cr)'), + ('w:tc/(w:tcPr, w:p, w:tbl, w:p)', 'foobar', + 'w:tc/(w:tcPr, w:p/w:r/w:t"foobar")'), + ]) + def text_set_fixture(self, request): + tc_cxml, new_text, expected_cxml = request.param + cell = _Cell(element(tc_cxml)) + expected_xml = xml(expected_cxml) + return cell, new_text, expected_xml @pytest.fixture - def cell_with_paragraphs(self): - tc = ( - a_tc().with_nsdecls() - .with_child(a_p()) - .with_child(a_p()) - .element - ) - return _Cell(tc) + def paragraphs_fixture(self): + return _Cell(element('w:tc/(w:p, w:p)')) class Describe_Column(object): - def it_provides_access_to_the_column_cells(self, column): + def it_provides_access_to_the_column_cells(self): + column = _Column(None, None) cells = column.cells assert isinstance(cells, _ColumnCells) @@ -181,43 +171,29 @@ def it_can_change_its_width(self, width_set_fixture): # fixtures ------------------------------------------------------- @pytest.fixture(params=[ - (4242, 2693670), - (1440, 914400), - ('2.54cm', 914400), - ('54mm', 1944000), - ('12.5pt', 158750), - (None, None), + ('w:gridCol{w:w=4242}', 2693670), + ('w:gridCol{w:w=1440}', 914400), + ('w:gridCol{w:w=2.54cm}', 914400), + ('w:gridCol{w:w=54mm}', 1944000), + ('w:gridCol{w:w=12.5pt}', 158750), + ('w:gridCol', None), ]) def width_get_fixture(self, request): - w, expected_width = request.param - gridCol = self.gridCol_bldr(w).element - column = _Column(gridCol, None) + gridCol_cxml, expected_width = request.param + column = _Column(element(gridCol_cxml), None) return column, expected_width @pytest.fixture(params=[ - (4242, None, None), - (None, None, None), - (4242, 914400, 1440), - (None, 914400, 1440), + ('w:gridCol', 914400, 'w:gridCol{w:w=1440}'), + ('w:gridCol{w:w=4242}', 457200, 'w:gridCol{w:w=720}'), + ('w:gridCol{w:w=4242}', None, 'w:gridCol'), + ('w:gridCol', None, 'w:gridCol'), ]) def width_set_fixture(self, request): - initial_w, value, expected_w = request.param - gridCol = self.gridCol_bldr(initial_w).element - column = _Column(gridCol, None) - expected_xml = self.gridCol_bldr(expected_w).xml() - return column, value, expected_xml - - # fixture components --------------------------------------------- - - @pytest.fixture - def column(self): - return _Column(None, None) - - def gridCol_bldr(self, w=None): - gridCol_bldr = a_gridCol().with_nsdecls() - if w is not None: - gridCol_bldr.with_w(w) - return gridCol_bldr + gridCol_cxml, new_value, expected_cxml = request.param + column = _Column(element(gridCol_cxml), None) + expected_xml = xml(expected_cxml) + return column, new_value, expected_xml class Describe_ColumnCells(object): @@ -301,19 +277,11 @@ def columns_fixture(self): class Describe_Row(object): - def it_provides_access_to_the_row_cells(self, cells_access_fixture): - row = cells_access_fixture + def it_provides_access_to_the_row_cells(self): + row = _Row(element('w:tr')) cells = row.cells assert isinstance(cells, _RowCells) - # fixtures ------------------------------------------------------- - - @pytest.fixture - def cells_access_fixture(self): - tr = a_tr().with_nsdecls().element - row = _Row(tr) - return row - class Describe_RowCells(object): @@ -348,12 +316,8 @@ def it_raises_on_indexed_access_out_of_range(self, cell_count_fixture): @pytest.fixture def cell_count_fixture(self): + cells = _RowCells(element('w:tr/(w:tc, w:tc)')) cell_count = 2 - tr_bldr = a_tr().with_nsdecls() - for idx in range(cell_count): - tr_bldr.with_child(a_tc()) - tr = tr_bldr.element - cells = _RowCells(tr) return cells, cell_count From b314f59168bda2298b878706de11faf2363f0d6e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 30 Jun 2014 23:48:50 -0700 Subject: [PATCH 153/809] opc: introduce XmlPart subclass Takes care of all the common responsibilities of XML-content parts, like parsing and reserializing the XML. --- docx/opc/package.py | 70 +++-- docx/parts/document.py | 31 +-- docx/parts/numbering.py | 20 +- docx/parts/styles.py | 20 +- tests/opc/test_package.py | 472 +++++++++++++++++++++------------- tests/parts/test_document.py | 162 +++--------- tests/parts/test_numbering.py | 90 +------ tests/parts/test_styles.py | 96 +------ 8 files changed, 379 insertions(+), 582 deletions(-) diff --git a/docx/opc/package.py b/docx/opc/package.py index b99b36eda..6c44453ce 100644 --- a/docx/opc/package.py +++ b/docx/opc/package.py @@ -10,6 +10,7 @@ from .compat import cls_method_fn from .constants import RELATIONSHIP_TYPE as RT from .oxml import CT_Relationships, serialize_part_xml +from ..oxml import parse_xml from .packuri import PACKAGE_URI, PackURI from .pkgreader import PackageReader from .pkgwriter import PackageWriter @@ -146,7 +147,6 @@ def save(self, pkg_file): Save this package to *pkg_file*, where *file* can be either a path to a file (a string) or a file-like object. """ - # self._notify_before_marshal() for part in self.parts: part.before_marshal() PackageWriter.write(pkg_file, self.rels, self.parts) @@ -158,18 +158,13 @@ class Part(object): intended to be subclassed in client code to implement specific part behaviors. """ - def __init__( - self, partname, content_type, blob=None, element=None, - package=None): + def __init__(self, partname, content_type, blob=None, package=None): super(Part, self).__init__() self._partname = partname self._content_type = content_type self._blob = blob - self._element = element self._package = package - # load/save interface to OpcPackage ------------------------------ - def after_unmarshal(self): """ Entry point for post-unmarshaling processing, for example to parse @@ -197,8 +192,6 @@ def blob(self): binary. Intended to be overridden by subclasses. Default behavior is to return load blob. """ - if self._element is not None: - return serialize_part_xml(self._element) return self._blob @property @@ -208,18 +201,25 @@ def content_type(self): """ return self._content_type + def drop_rel(self, rId): + """ + Remove the relationship identified by *rId* if its reference count + is less than 2. Relationships with a reference count of 0 are + implicit relationships. + """ + if self._rel_ref_count(rId) < 2: + del self.rels[rId] + @classmethod def load(cls, partname, content_type, blob, package): - return cls( - partname, content_type, blob=blob, element=None, package=package - ) + return cls(partname, content_type, blob, package) def load_rel(self, reltype, target, rId, is_external=False): """ Return newly added |_Relationship| instance of *reltype* between this part and *target* with key *rId*. Target mode is set to ``RTM.EXTERNAL`` if *is_external* is |True|. Intended for use during - load from a serialized package, where the rId is well known. Other + load from a serialized package, where the rId is well-known. Other methods exist for adding a new relationship to a part when manipulating a part. """ @@ -240,16 +240,12 @@ def partname(self, partname): raise TypeError(tmpl % type(partname).__name__) self._partname = partname - # relationship management interface for child objects ------------ - - def drop_rel(self, rId): + @property + def package(self): """ - Remove the relationship identified by *rId* if its reference count - is less than 2. Relationships with a reference count of 0 are - implicit relationships. + |OpcPackage| instance this part belongs to. """ - if self._rel_ref_count(rId) < 2: - del self.rels[rId] + return self._package def part_related_by(self, reltype): """ @@ -300,18 +296,40 @@ def _rel_ref_count(self, rId): Return the count of references in this part's XML to the relationship identified by *rId*. """ - assert self._element is not None rIds = self._element.xpath('//@r:id') return len([_rId for _rId in rIds if _rId == rId]) - # ---------------------------------------------------------------- + +class XmlPart(Part): + """ + Base class for package parts containing an XML payload, which is most of + them. Provides additional methods to the |Part| base class that take care + of parsing and reserializing the XML payload and managing relationships + to other parts. + """ + def __init__(self, partname, content_type, element, package): + super(XmlPart, self).__init__( + partname, content_type, package=package + ) + self._element = element @property - def package(self): + def blob(self): + return serialize_part_xml(self._element) + + @classmethod + def load(cls, partname, content_type, blob, package): + element = parse_xml(blob) + return cls(partname, content_type, element, package) + + @property + def part(self): """ - |OpcPackage| instance this part belongs to. + Part of the parent protocol, "children" of the document will not know + the part that contains them so must ask their parent object. That + chain of delegation ends here for child objects. """ - return self._package + return self class PartFactory(object): diff --git a/docx/parts/document.py b/docx/parts/document.py index 3e1449491..9085e0ccf 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -12,9 +12,7 @@ from ..enum.section import WD_SECTION from ..opc.constants import RELATIONSHIP_TYPE as RT -from ..opc.oxml import serialize_part_xml -from ..opc.package import Part -from ..oxml import parse_xml +from ..opc.package import XmlPart from ..section import Section from ..shape import InlineShape from ..shared import lazyproperty, Parented @@ -22,16 +20,10 @@ from ..text import Paragraph -class DocumentPart(Part): +class DocumentPart(XmlPart): """ Main document part of a WordprocessingML (WML) package, aka a .docx file. """ - def __init__(self, partname, content_type, document_elm, package): - super(DocumentPart, self).__init__( - partname, content_type, package=package - ) - self._element = document_elm - def add_paragraph(self): """ Return a paragraph newly added to the end of body content. @@ -54,10 +46,6 @@ def add_table(self, rows, cols): """ return self.body.add_table(rows, cols) - @property - def blob(self): - return serialize_part_xml(self._element) - @lazyproperty def body(self): """ @@ -86,12 +74,6 @@ def inline_shapes(self): """ return InlineShapes(self._element.body, self) - @classmethod - def load(cls, partname, content_type, blob, package): - document_elm = parse_xml(blob) - document_part = cls(partname, content_type, document_elm, package) - return document_part - @property def next_id(self): """ @@ -114,15 +96,6 @@ def paragraphs(self): """ return self.body.paragraphs - @property - def part(self): - """ - Part of the parent protocol, "children" of the document will not know - the part that contains them so must ask their parent object. That - chain of delegation ends here for document child objects. - """ - return self - @lazyproperty def sections(self): """ diff --git a/docx/parts/numbering.py b/docx/parts/numbering.py index 1b8242cc9..e9c8f713d 100644 --- a/docx/parts/numbering.py +++ b/docx/parts/numbering.py @@ -8,31 +8,15 @@ absolute_import, division, print_function, unicode_literals ) -from ..opc.package import Part -from ..oxml import parse_xml +from ..opc.package import XmlPart from ..shared import lazyproperty -class NumberingPart(Part): +class NumberingPart(XmlPart): """ Proxy for the numbering.xml part containing numbering definitions for a document or glossary. """ - def __init__(self, partname, content_type, element, package): - super(NumberingPart, self).__init__( - partname, content_type, element=element, package=package - ) - - @classmethod - def load(cls, partname, content_type, blob, package): - """ - Provides PartFactory interface for loading a numbering part from - a WML package. - """ - numbering_elm = parse_xml(blob) - numbering_part = cls(partname, content_type, numbering_elm, package) - return numbering_part - @classmethod def new(cls): """ diff --git a/docx/parts/styles.py b/docx/parts/styles.py index f6e544aeb..d9f4cfda9 100644 --- a/docx/parts/styles.py +++ b/docx/parts/styles.py @@ -8,31 +8,15 @@ absolute_import, division, print_function, unicode_literals ) -from ..opc.package import Part -from ..oxml import parse_xml +from ..opc.package import XmlPart from ..shared import lazyproperty -class StylesPart(Part): +class StylesPart(XmlPart): """ Proxy for the styles.xml part containing style definitions for a document or glossary. """ - def __init__(self, partname, content_type, element, package): - super(StylesPart, self).__init__( - partname, content_type, element=element, package=package - ) - - @classmethod - def load(cls, partname, content_type, blob, package): - """ - Provides PartFactory interface for loading a styles part from a WML - package. - """ - styles_elm = parse_xml(blob) - styles_part = cls(partname, content_type, styles_elm, package) - return styles_part - @classmethod def new(cls): """ diff --git a/tests/opc/test_package.py b/tests/opc/test_package.py index fdb218585..aa0eff574 100644 --- a/tests/opc/test_package.py +++ b/tests/opc/test_package.py @@ -12,13 +12,15 @@ from docx.opc.packuri import PACKAGE_URI, PackURI from docx.opc.package import ( OpcPackage, Part, PartFactory, _Relationship, Relationships, - Unmarshaller + Unmarshaller, XmlPart ) from docx.opc.pkgreader import PackageReader +from docx.oxml.xmlchemy import BaseOxmlElement +from ..unitutil.cxml import element from ..unitutil.mock import ( - call, class_mock, cls_attr_mock, function_mock, instance_mock, - loose_mock, method_mock, Mock, patch, PropertyMock + call, class_mock, cls_attr_mock, function_mock, initializer_mock, + instance_mock, loose_mock, method_mock, Mock, patch, PropertyMock ) @@ -199,18 +201,32 @@ def Unmarshaller_(self, request): return class_mock(request, 'docx.opc.package.Unmarshaller') -class DescribePartLoadSaveInterface(object): +class DescribePart(object): - def it_remembers_its_construction_state(self): - partname, content_type, blob, element, package = ( - Mock(name='partname'), Mock(name='content_type'), - Mock(name='blob'), None, Mock(name='package') + def it_can_be_constructed_by_PartFactory(self, load_fixture): + partname_, content_type_, blob_, package_, __init_ = load_fixture + part = Part.load(partname_, content_type_, blob_, package_) + __init_.assert_called_once_with( + partname_, content_type_, blob_, package_ ) - part = Part(partname, content_type, blob, element, package) - assert part.partname == partname - assert part.content_type == content_type - assert part.blob == blob - assert part.package == package + assert isinstance(part, Part) + + def it_knows_its_partname(self, partname_get_fixture): + part, expected_partname = partname_get_fixture + assert part.partname == expected_partname + + def it_can_change_its_partname(self, partname_set_fixture): + part, new_partname = partname_set_fixture + part.partname = new_partname + assert part.partname == new_partname + + def it_knows_its_content_type(self, content_type_fixture): + part, expected_content_type = content_type_fixture + assert part.content_type == expected_content_type + + def it_knows_the_package_it_belongs_to(self, package_get_fixture): + part, expected_package = package_get_fixture + assert part.package == expected_package def it_can_be_notified_after_unmarshalling_is_complete(self, part): part.after_unmarshal() @@ -218,164 +234,230 @@ def it_can_be_notified_after_unmarshalling_is_complete(self, part): def it_can_be_notified_before_marshalling_is_started(self, part): part.before_marshal() - def it_allows_its_partname_to_be_changed(self, part): - new_partname = PackURI('/ppt/presentation.xml') - part.partname = new_partname - assert part.partname == new_partname - - def it_can_load_a_relationship_during_package_open( - self, part_with_rels_, rel_attrs_): - # fixture ---------------------- - part, rels_ = part_with_rels_ - reltype, target, rId = rel_attrs_ - # exercise --------------------- - part.load_rel(reltype, target, rId) - # verify ----------------------- - rels_.add_relationship.assert_called_once_with( - reltype, target, rId, False - ) + def it_uses_the_load_blob_as_its_blob(self, blob_fixture): + part, load_blob = blob_fixture + assert part.blob is load_blob # fixtures --------------------------------------------- + @pytest.fixture + def blob_fixture(self, blob_): + part = Part(None, None, blob_, None) + return part, blob_ + + @pytest.fixture + def content_type_fixture(self): + content_type = 'content/type' + part = Part(None, content_type, None, None) + return part, content_type + + @pytest.fixture + def load_fixture( + self, request, partname_, content_type_, blob_, package_, + __init_): + return (partname_, content_type_, blob_, package_, __init_) + + @pytest.fixture + def package_get_fixture(self, package_): + part = Part(None, None, None, package_) + return part, package_ + @pytest.fixture def part(self): - partname = PackURI('/foo/bar.xml') - part = Part(partname, None, None) + part = Part(None, None) return part @pytest.fixture - def part_with_rels_(self, request, part, rels_): - part._rels = rels_ - return part, rels_ + def partname_get_fixture(self): + partname = PackURI('/part/name') + part = Part(partname, None, None, None) + return part, partname @pytest.fixture - def rel_attrs_(self, request): - reltype = 'http://rel/type' - target_ = instance_mock(request, Part, name='target_') - rId = 'rId99' - return reltype, target_, rId + def partname_set_fixture(self): + old_partname = PackURI('/old/part/name') + new_partname = PackURI('/new/part/name') + part = Part(old_partname, None, None, None) + return part, new_partname + + # fixture components --------------------------------------------- @pytest.fixture - def rels_(self, request): - return instance_mock(request, Relationships) + def blob_(self, request): + return instance_mock(request, bytes) + @pytest.fixture + def content_type_(self, request): + return instance_mock(request, str) -class DescribePartRelsProxyInterface(object): + @pytest.fixture + def __init_(self, request): + return initializer_mock(request, Part) - def it_has_a_rels_collection_initialized_on_first_reference( - self, Relationships_): - partname = PackURI('/foo/bar.xml') - part = Part(partname, None, None) - assert part.rels is Relationships_.return_value - Relationships_.assert_called_once_with(partname.baseURI) + @pytest.fixture + def package_(self, request): + return instance_mock(request, OpcPackage) - def it_can_establish_a_relationship_to_another_part( - self, relate_to_part_fixture_): - # fixture ---------------------- - part, related_part_, reltype, rId = relate_to_part_fixture_ - # exercise --------------------- - _rId = part.relate_to(related_part_, reltype) - # verify ----------------------- - part.rels.get_or_add.assert_called_once_with(reltype, related_part_) - assert _rId == rId + @pytest.fixture + def partname_(self, request): + return instance_mock(request, PackURI) - 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) - assert _rId == rId - # def it_can_drop_a_relationship(self, part_with_rels_to_drop_): - # part, rId, rId_2, rId_3 = part_with_rels_to_drop_ - # part.drop_rel(rId) # this one has ref count of 2, don't drop - # part.drop_rel(rId_2) # this one has ref count of 1, drop - # part.drop_rel(rId_3) # this one has ref count of 0, drop - # assert part.rels.__delitem__.call_args_list == [ - # call(rId_2), call(rId_3) - # ] +class DescribePartRelationshipManagementInterface(object): - def it_can_find_a_part_related_by_reltype(self, related_part_fixture_): - part, reltype, related_part_ = related_part_fixture_ - related_part = part.part_related_by(reltype) - part.rels.part_with_reltype.assert_called_once_with(reltype) + def it_provides_access_to_its_relationships(self, rels_fixture): + part, Relationships_, partname_, rels_ = rels_fixture + rels = part.rels + Relationships_.assert_called_once_with(partname_.baseURI) + assert rels is rels_ + + 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 + ) + + 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): + 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_) + assert rId is rId_ + + def it_can_drop_a_relationship(self, drop_rel_fixture): + part, rId, rel_should_be_gone = drop_rel_fixture + part.drop_rel(rId) + if rel_should_be_gone: + assert rId not in part.rels + else: + assert rId in part.rels + + def it_can_find_a_related_part_by_reltype(self, related_part_fixture): + part, reltype_, related_part_ = related_part_fixture + related_part = part.part_related_by(reltype_) + part.rels.part_with_reltype.assert_called_once_with(reltype_) assert related_part is related_part_ - def it_can_find_the_target_ref_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_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): + part, rId_, url_ = target_ref_fixture + url = part.target_ref(rId_) + assert url == url_ # fixtures --------------------------------------------- - @pytest.fixture - def part(self): - partname = PackURI('/foo/bar.xml') - part = Part(partname, None, None) - return part + @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' + part._element = element(part_cxml) + part._rels = {rId: None} + return part, rId, rel_should_be_dropped - # @pytest.fixture - # def part_with_rels_to_drop_(self, request, part, rels_): - # rId, rId_2, rId3 = 'rId1', 'rId2', 'rId3' - # _element = ( - # an_rPr().with_nsdecls('a', 'r') - # .with_child(an_hlinkClick().with_rId(rId)) - # .with_child(an_hlinkClick().with_rId(rId)) - # .with_child(an_hlinkClick().with_rId(rId_2)) - # .element - # ) - # part._element = _element - # part._rels = rels_ - # return part, rId, rId_2, rId3 + @pytest.fixture + def load_rel_fixture(self, part, rels_, reltype_, part_, rId_): + part._rels = rels_ + return part, rels_, reltype_, part_, rId_ @pytest.fixture - def Relationships_(self, request): - return class_mock(request, 'docx.opc.package.Relationships') + 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_part_fixture_(self, request, part, reltype): - rId = 'rId99' - related_part_ = instance_mock(request, Part, name='related_part_') - rels_ = instance_mock(request, Relationships, name='rels_') - rel_ = instance_mock(request, _Relationship, name='rel_', rId=rId) - rels_.get_or_add.return_value = rel_ + def relate_to_url_fixture( + self, request, part, rels_, url_, reltype_, rId_): part._rels = rels_ - return part, related_part_, reltype, rId + return part, url_, reltype_, rId_ @pytest.fixture - def relate_to_url_fixture_(self, request, part, reltype): - rId = 'rId21' - url = 'https://github.com/scanny/python-docx' - rels_ = instance_mock(request, Relationships, name='rels_') - rels_.get_or_add_ext_rel.return_value = rId + def related_part_fixture(self, request, part, rels_, reltype_, part_): part._rels = rels_ - return part, url, reltype, rId + return part, reltype_, part_ @pytest.fixture - def related_part_fixture_(self, request, part, reltype): - related_part_ = instance_mock(request, Part, name='related_part_') - rels_ = instance_mock(request, Relationships, name='rels_') - rels_.part_with_reltype.return_value = related_part_ + def related_parts_fixture(self, request, part, rels_, related_parts_): part._rels = rels_ - return part, reltype, related_part_ + return part, related_parts_ @pytest.fixture - def reltype(self): - return 'http:/rel/type' + def rels_fixture(self, Relationships_, partname_, rels_): + part = Part(partname_, None) + return part, Relationships_, partname_, rels_ @pytest.fixture - def rels_(self, request): - return instance_mock(request, Relationships) + def target_ref_fixture(self, request, part, rId_, rel_, url_): + part._rels = {rId_: rel_} + return part, rId_, url_ + + # fixture components --------------------------------------------- @pytest.fixture - def target_ref_fixture_(self, request, part): - rId = 'rId246' - url = 'https://github.com/scanny/python-docx' - rels = Relationships(None) - rels.add_relationship(None, url, rId, is_external=True) - part._rels = rels - return part, rId, url + def part(self): + return Part(None, None) + + @pytest.fixture + def part_(self, request): + return instance_mock(request, Part) + + @pytest.fixture + def partname_(self, request): + return instance_mock(request, PackURI) + + @pytest.fixture + def Relationships_(self, request, rels_): + return class_mock( + request, 'docx.opc.package.Relationships', return_value=rels_ + ) + + @pytest.fixture + def rel_(self, request, rId_, url_): + return instance_mock( + request, _Relationship, rId=rId_, target_ref=url_ + ) + + @pytest.fixture + def rels_(self, request, part_, rel_, rId_, related_parts_): + rels_ = instance_mock(request, Relationships) + rels_.part_with_reltype.return_value = part_ + rels_.get_or_add.return_value = rel_ + rels_.get_or_add_ext_rel.return_value = rId_ + rels_.related_parts = related_parts_ + return rels_ + + @pytest.fixture + def related_parts_(self, request): + return instance_mock(request, dict) + + @pytest.fixture + def reltype_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def rId_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def url_(self, request): + return instance_mock(request, str) class DescribePartFactory(object): @@ -838,54 +920,84 @@ def _unmarshal_relationships(self, request): return method_mock(request, Unmarshaller, '_unmarshal_relationships') -# from ..oxml.unitdata.text import an_hlinkClick, an_rPr -# from ..unitutil import ( -# absjoin, class_mock, cls_attr_mock, instance_mock, loose_mock, -# method_mock, test_file_dir -# ) -# test_docx_path = absjoin(test_file_dir, 'test.docx') -# dir_pkg_path = absjoin(test_file_dir, 'expanded_docx') -# zip_pkg_path = test_docx_path - -# def test_it_finds_default_case_insensitive(self, cti): -# """_ContentTypesItem[partname] finds default case insensitive""" -# # setup ------------------------ -# partname = '/ppt/media/image1.JPG' -# content_type = 'image/jpeg' -# cti._defaults = {'jpg': content_type} -# # exercise --------------------- -# val = cti[partname] -# # verify ----------------------- -# assert val == content_type - -# def test_it_finds_override_case_insensitive(self, cti): -# """_ContentTypesItem[partname] finds override case insensitive""" -# # setup ------------------------ -# partname = '/foo/bar.xml' -# case_mangled_partname = '/FoO/bAr.XML' -# content_type = 'application/vnd.content_type' -# cti._overrides = { -# partname: content_type -# } -# # exercise --------------------- -# val = cti[case_mangled_partname] -# # verify ----------------------- -# assert val == content_type - -# def test_save_accepts_stream(self, tmp_docx_path): -# pkg = Package().open(dir_pkg_path) -# stream = StringIO() -# # exercise -------------------- -# pkg.save(stream) -# # verify ---------------------- -# # can't use is_zipfile() directly on stream in Python 2.6 -# stream.seek(0) -# with open(tmp_docx_path, 'wb') as f: -# f.write(stream.read()) -# msg = "Package.save(stream) did not create zipfile" -# assert is_zipfile(tmp_docx_path), msg - - -# @pytest.fixture -# def tmp_docx_path(tmpdir): -# return str(tmpdir.join('test_python-docx.docx')) +class DescribeXmlPart(object): + + def it_can_be_constructed_by_PartFactory(self, load_fixture): + partname_, content_type_, blob_, package_ = load_fixture[:4] + element_, parse_xml_, __init_ = load_fixture[4:] + # exercise --------------------- + part = XmlPart.load(partname_, content_type_, blob_, package_) + # verify ----------------------- + parse_xml_.assert_called_once_with(blob_) + __init_.assert_called_once_with( + partname_, content_type_, element_, package_ + ) + assert isinstance(part, XmlPart) + + def it_can_serialize_to_xml(self, blob_fixture): + xml_part, element_, serialize_part_xml_ = blob_fixture + blob = xml_part.blob + serialize_part_xml_.assert_called_once_with(element_) + assert blob is serialize_part_xml_.return_value + + def it_knows_its_the_part_for_its_child_objects(self, part_fixture): + xml_part = part_fixture + assert xml_part.part is xml_part + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def blob_fixture(self, request, element_, serialize_part_xml_): + xml_part = XmlPart(None, None, element_, None) + return xml_part, element_, serialize_part_xml_ + + @pytest.fixture + def load_fixture( + self, request, partname_, content_type_, blob_, package_, + element_, parse_xml_, __init_): + return ( + partname_, content_type_, blob_, package_, element_, parse_xml_, + __init_ + ) + + @pytest.fixture + def part_fixture(self): + return XmlPart(None, None, None, None) + + # fixture components --------------------------------------------- + + @pytest.fixture + def blob_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def content_type_(self, request): + return instance_mock(request, str) + + @pytest.fixture + def element_(self, request): + return instance_mock(request, BaseOxmlElement) + + @pytest.fixture + def __init_(self, request): + return initializer_mock(request, XmlPart) + + @pytest.fixture + def package_(self, request): + return instance_mock(request, OpcPackage) + + @pytest.fixture + def parse_xml_(self, request, element_): + return function_mock( + request, 'docx.opc.package.parse_xml', return_value=element_ + ) + + @pytest.fixture + def partname_(self, request): + return instance_mock(request, PackURI) + + @pytest.fixture + def serialize_part_xml_(self, request): + return function_mock( + request, 'docx.opc.package.serialize_part_xml' + ) diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 60d103bbf..e715417a2 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -8,9 +8,7 @@ import pytest -from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT -from docx.opc.package import PartFactory -from docx.opc.packuri import PackURI +from docx.opc.constants import RELATIONSHIP_TYPE as RT from docx.oxml.parts.document import CT_Body, CT_Document from docx.oxml.section import CT_SectPr from docx.oxml.text import CT_R @@ -29,45 +27,41 @@ from ..oxml.unitdata.text import a_p, a_sectPr from ..unitutil.cxml import element, xml from ..unitutil.mock import ( - function_mock, class_mock, initializer_mock, instance_mock, loose_mock, - method_mock, Mock, property_mock + instance_mock, class_mock, loose_mock, method_mock, property_mock ) class DescribeDocumentPart(object): - def it_is_used_by_PartFactory_to_construct_main_document_part( - self, part_load_fixture): - # fixture ---------------------- - document_part_load_, partname_, blob_, package_, document_part_ = ( - part_load_fixture - ) - content_type = CT.WML_DOCUMENT_MAIN - reltype = RT.OFFICE_DOCUMENT - # exercise --------------------- - part = PartFactory(partname_, content_type, reltype, blob_, package_) - # verify ----------------------- - document_part_load_.assert_called_once_with( - partname_, content_type, blob_, package_ - ) - assert part is document_part_ - - def it_can_be_constructed_by_opc_part_factory(self, parse_xml_, init): - # mockery ---------------------- - partname, content_type, blob, document_elm, package = ( - Mock(name='partname'), Mock(name='content_type'), - Mock(name='blob'), Mock(name='document_elm'), - Mock(name='package') - ) - parse_xml_.return_value = document_elm - # exercise --------------------- - doc = DocumentPart.load(partname, content_type, blob, package) - # verify ----------------------- - parse_xml_.assert_called_once_with(blob) - init.assert_called_once_with( - partname, content_type, document_elm, package - ) - assert isinstance(doc, DocumentPart) + def it_has_a_body(self, document_body_fixture): + document, _Body_, body_elm = document_body_fixture + _body = document.body + _Body_.assert_called_once_with(body_elm) + assert _body is _Body_.return_value + + def it_provides_access_to_the_document_paragraphs( + self, paragraphs_fixture): + document_part, paragraphs_ = paragraphs_fixture + paragraphs = document_part.paragraphs + assert paragraphs is paragraphs_ + + def it_provides_access_to_the_document_sections(self, sections_fixture): + document, document_elm, Sections_ = sections_fixture + sections = document.sections + Sections_.assert_called_once_with(document_elm) + assert sections is Sections_.return_value + + def it_provides_access_to_the_document_tables(self, tables_fixture): + document_part, tables_ = tables_fixture + tables = document_part.tables + assert tables is tables_ + + 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_add_a_paragraph(self, add_paragraph_fixture): document_part, body_, p_ = add_paragraph_fixture @@ -102,47 +96,6 @@ def it_can_add_an_image_part_to_the_document( assert image_part is image_part_ assert rId == rId_ - def it_has_a_body(self, document_body_fixture): - document, _Body_, body_elm = document_body_fixture - _body = document.body - _Body_.assert_called_once_with(body_elm) - assert _body is _Body_.return_value - - def it_can_serialize_to_xml(self, document_blob_fixture): - document_part, document_elm, serialize_part_xml_ = ( - document_blob_fixture - ) - blob = document_part.blob - serialize_part_xml_.assert_called_once_with(document_elm) - assert blob is serialize_part_xml_.return_value - - def it_provides_access_to_the_document_paragraphs( - self, paragraphs_fixture): - document_part, paragraphs_ = paragraphs_fixture - paragraphs = document_part.paragraphs - assert paragraphs is paragraphs_ - - def it_provides_access_to_the_document_sections(self, sections_fixture): - document, document_elm, Sections_ = sections_fixture - sections = document.sections - Sections_.assert_called_once_with(document_elm) - assert sections is Sections_.return_value - - def it_provides_access_to_the_document_tables(self, tables_fixture): - document_part, tables_ = tables_fixture - tables = document_part.tables - assert tables is tables_ - - 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_knows_it_is_the_part_its_child_objects_belong_to(self, document): - assert document.part is document - def it_knows_the_next_available_xml_id(self, next_id_fixture): document, expected_id = next_id_fixture assert document.next_id == expected_id @@ -170,12 +123,6 @@ def add_table_fixture(self, document_part_body_, body_, table_): rows, cols = 2, 4 return document_part, rows, cols, body_, table_ - @pytest.fixture - def document_blob_fixture(self, request, serialize_part_xml_): - document_elm = instance_mock(request, CT_Document) - document_part = DocumentPart(None, None, document_elm, None) - return document_part, document_elm, serialize_part_xml_ - @pytest.fixture def document_body_fixture(self, request, _Body_): document_elm = ( @@ -247,36 +194,16 @@ def body_elm_(self, request, sectPr_): body_elm_.add_section_break.return_value = sectPr_ return body_elm_ - @pytest.fixture - def blob_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def content_type_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def document(self): - return DocumentPart(None, None, None, None) - @pytest.fixture def document_elm_(self, request, body_elm_): return instance_mock(request, CT_Document, body=body_elm_) - @pytest.fixture - def document_part_(self, request): - return instance_mock(request, DocumentPart) - @pytest.fixture def document_part_body_(self, request, body_): return property_mock( request, DocumentPart, 'body', return_value=body_ ) - @pytest.fixture - def document_part_load_(self, request): - return method_mock(request, DocumentPart, 'load') - @pytest.fixture def get_or_add_image_fixture( self, request, package_, image_descriptor_, image_parts_, @@ -302,18 +229,10 @@ def image_parts_(self, request, image_part_): image_parts_.get_or_add_image_part.return_value = image_part_ return image_parts_ - @pytest.fixture - def init(self, request): - return initializer_mock(request, DocumentPart) - @pytest.fixture def InlineShapes_(self, request): return class_mock(request, 'docx.parts.document.InlineShapes') - @pytest.fixture - def parse_xml_(self, request): - return function_mock(request, 'docx.parts.document.parse_xml') - @pytest.fixture def p_(self, request): return instance_mock(request, Paragraph) @@ -326,19 +245,6 @@ def package_(self, request): def paragraphs_(self, request): return instance_mock(request, list) - @pytest.fixture - def part_load_fixture( - self, document_part_load_, partname_, blob_, package_, - document_part_): - document_part_load_.return_value = document_part_ - return ( - document_part_load_, partname_, blob_, package_, document_part_ - ) - - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) - @pytest.fixture def relate_to_(self, request, rId_): relate_to_ = method_mock(request, DocumentPart, 'relate_to') @@ -367,12 +273,6 @@ def Sections_(self, request): def sectPr_(self, request): return instance_mock(request, CT_SectPr) - @pytest.fixture - def serialize_part_xml_(self, request): - return function_mock( - request, 'docx.parts.document.serialize_part_xml' - ) - @pytest.fixture def start_type_(self, request): return instance_mock(request, int) diff --git a/tests/parts/test_numbering.py b/tests/parts/test_numbering.py index e93ebe03c..24b242d5a 100644 --- a/tests/parts/test_numbering.py +++ b/tests/parts/test_numbering.py @@ -8,50 +8,15 @@ import pytest -from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT -from docx.opc.package import PartFactory -from docx.opc.packuri import PackURI from docx.oxml.parts.numbering import CT_Numbering -from docx.package import Package from docx.parts.numbering import NumberingPart, _NumberingDefinitions from ..oxml.unitdata.numbering import a_num, a_numbering -from ..unitutil.mock import ( - function_mock, class_mock, initializer_mock, instance_mock, method_mock -) +from ..unitutil.mock import class_mock, instance_mock class DescribeNumberingPart(object): - def it_is_used_by_PartFactory_to_construct_numbering_part( - self, load_fixture): - # fixture ---------------------- - numbering_part_load_, partname_, blob_, package_, numbering_part_ = ( - load_fixture - ) - content_type, reltype = CT.WML_NUMBERING, RT.NUMBERING - # exercise --------------------- - part = PartFactory(partname_, content_type, reltype, blob_, package_) - # verify ----------------------- - numbering_part_load_.assert_called_once_with( - partname_, content_type, blob_, package_ - ) - assert part is numbering_part_ - - def it_can_be_constructed_by_opc_part_factory(self, construct_fixture): - (partname_, content_type_, blob_, package_, parse_xml_, init__, - numbering_elm_) = construct_fixture - # exercise --------------------- - numbering_part = NumberingPart.load( - partname_, content_type_, blob_, package_ - ) - # verify ----------------------- - parse_xml_.assert_called_once_with(blob_) - init__.assert_called_once_with( - partname_, content_type_, numbering_elm_, package_ - ) - assert isinstance(numbering_part, NumberingPart) - def it_provides_access_to_the_numbering_definitions( self, num_defs_fixture): (numbering_part, _NumberingDefinitions_, numbering_elm_, @@ -62,24 +27,6 @@ def it_provides_access_to_the_numbering_definitions( # fixtures ------------------------------------------------------- - @pytest.fixture - def construct_fixture( - self, partname_, content_type_, blob_, package_, parse_xml_, - init__, numbering_elm_): - return ( - partname_, content_type_, blob_, package_, parse_xml_, init__, - numbering_elm_ - ) - - @pytest.fixture - def load_fixture( - self, numbering_part_load_, partname_, blob_, package_, - numbering_part_): - numbering_part_load_.return_value = numbering_part_ - return ( - numbering_part_load_, partname_, blob_, package_, numbering_part_ - ) - @pytest.fixture def num_defs_fixture( self, _NumberingDefinitions_, numbering_elm_, @@ -92,18 +39,6 @@ def num_defs_fixture( # fixture components --------------------------------------------- - @pytest.fixture - def blob_(self, request): - return instance_mock(request, bytes) - - @pytest.fixture - def content_type_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def init__(self, request): - return initializer_mock(request, NumberingPart) - @pytest.fixture def _NumberingDefinitions_(self, request, numbering_definitions_): return class_mock( @@ -119,29 +54,6 @@ def numbering_definitions_(self, request): def numbering_elm_(self, request): return instance_mock(request, CT_Numbering) - @pytest.fixture - def numbering_part_(self, request): - return instance_mock(request, NumberingPart) - - @pytest.fixture - def numbering_part_load_(self, request): - return method_mock(request, NumberingPart, 'load') - - @pytest.fixture - def parse_xml_(self, request, numbering_elm_): - return function_mock( - request, 'docx.parts.numbering.parse_xml', - return_value=numbering_elm_ - ) - - @pytest.fixture - def package_(self, request): - return instance_mock(request, Package) - - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) - class Describe_NumberingDefinitions(object): diff --git a/tests/parts/test_styles.py b/tests/parts/test_styles.py index af845859d..3294546c7 100644 --- a/tests/parts/test_styles.py +++ b/tests/parts/test_styles.py @@ -8,50 +8,15 @@ import pytest -from docx.opc.constants import CONTENT_TYPE as CT, RELATIONSHIP_TYPE as RT -from docx.opc.package import PartFactory -from docx.opc.packuri import PackURI from docx.oxml.parts.styles import CT_Styles -from docx.package import Package from docx.parts.styles import StylesPart, _Styles from ..oxml.unitdata.styles import a_style, a_styles -from ..unitutil.mock import ( - function_mock, class_mock, initializer_mock, instance_mock, method_mock -) +from ..unitutil.mock import class_mock, instance_mock class DescribeStylesPart(object): - def it_is_used_by_PartFactory_to_construct_styles_part( - self, load_fixture): - # fixture ---------------------- - styles_part_load_, partname_, blob_, package_, styles_part_ = ( - load_fixture - ) - content_type, reltype = CT.WML_STYLES, RT.STYLES - # exercise --------------------- - part = PartFactory(partname_, content_type, reltype, blob_, package_) - # verify ----------------------- - styles_part_load_.assert_called_once_with( - partname_, content_type, blob_, package_ - ) - assert part is styles_part_ - - def it_can_be_constructed_by_opc_part_factory(self, construct_fixture): - (partname_, content_type_, blob_, package_, parse_xml_, init__, - styles_elm_) = construct_fixture - # exercise --------------------- - styles_part = StylesPart.load( - partname_, content_type_, blob_, package_ - ) - # verify ----------------------- - parse_xml_.assert_called_once_with(blob_) - init__.assert_called_once_with( - partname_, content_type_, styles_elm_, package_ - ) - assert isinstance(styles_part, StylesPart) - def it_provides_access_to_the_styles(self, styles_fixture): styles_part, _Styles_, styles_elm_, styles_ = styles_fixture styles = styles_part.styles @@ -61,49 +26,11 @@ def it_provides_access_to_the_styles(self, styles_fixture): # fixtures ------------------------------------------------------- @pytest.fixture - def blob_(self, request): - return instance_mock(request, bytes) - - @pytest.fixture - def construct_fixture( - self, partname_, content_type_, blob_, package_, parse_xml_, - init__, styles_elm_): - return ( - partname_, content_type_, blob_, package_, parse_xml_, init__, - styles_elm_ - ) - - @pytest.fixture - def content_type_(self, request): - return instance_mock(request, str) - - @pytest.fixture - def init__(self, request): - return initializer_mock(request, StylesPart) - - @pytest.fixture - def load_fixture( - self, styles_part_load_, partname_, blob_, package_, - styles_part_): - styles_part_load_.return_value = styles_part_ - return ( - styles_part_load_, partname_, blob_, package_, styles_part_ - ) - - @pytest.fixture - def parse_xml_(self, request, styles_elm_): - return function_mock( - request, 'docx.parts.styles.parse_xml', - return_value=styles_elm_ - ) - - @pytest.fixture - def package_(self, request): - return instance_mock(request, Package) + def styles_fixture(self, _Styles_, styles_elm_, styles_): + styles_part = StylesPart(None, None, styles_elm_, None) + return styles_part, _Styles_, styles_elm_, styles_ - @pytest.fixture - def partname_(self, request): - return instance_mock(request, PackURI) + # fixture components --------------------------------------------- @pytest.fixture def _Styles_(self, request, styles_): @@ -119,19 +46,6 @@ def styles_(self, request): def styles_elm_(self, request): return instance_mock(request, CT_Styles) - @pytest.fixture - def styles_fixture(self, _Styles_, styles_elm_, styles_): - styles_part = StylesPart(None, None, styles_elm_, None) - return styles_part, _Styles_, styles_elm_, styles_ - - @pytest.fixture - def styles_part_(self, request): - return instance_mock(request, StylesPart) - - @pytest.fixture - def styles_part_load_(self, request): - return method_mock(request, StylesPart, 'load') - class Describe_Styles(object): From 5908c85d424f47126cc64f3c436422b7940476b2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 16:39:10 -0700 Subject: [PATCH 154/809] doc: base _Body on Parented Improved some names along the way, document_part instead of document where an instance of DocumentPart. --- docx/parts/document.py | 8 ++++---- tests/parts/test_document.py | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docx/parts/document.py b/docx/parts/document.py index 9085e0ccf..4b2542869 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -51,7 +51,7 @@ def body(self): """ The |_Body| instance containing the content for this document. """ - return _Body(self._element.body) + return _Body(self._element.body, self) def get_or_add_image_part(self, image_descriptor): """ @@ -113,13 +113,13 @@ def tables(self): return self.body.tables -class _Body(object): +class _Body(Parented): """ Proxy for ```` element in this document, having primarily a container role. """ - def __init__(self, body_elm): - super(_Body, self).__init__() + def __init__(self, body_elm, parent): + super(_Body, self).__init__(parent) self._body = body_elm def add_paragraph(self): diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index e715417a2..14f1969a7 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -33,10 +33,10 @@ class DescribeDocumentPart(object): - def it_has_a_body(self, document_body_fixture): - document, _Body_, body_elm = document_body_fixture - _body = document.body - _Body_.assert_called_once_with(body_elm) + def it_has_a_body(self, body_fixture): + document_part, _Body_, body_elm = body_fixture + _body = document_part.body + _Body_.assert_called_once_with(body_elm, document_part) assert _body is _Body_.return_value def it_provides_access_to_the_document_paragraphs( @@ -124,14 +124,14 @@ def add_table_fixture(self, document_part_body_, body_, table_): return document_part, rows, cols, body_, table_ @pytest.fixture - def document_body_fixture(self, request, _Body_): + def body_fixture(self, request, _Body_): 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, _Body_, body_elm + document_part = DocumentPart(None, None, document_elm, None) + return document_part, _Body_, body_elm @pytest.fixture def inline_shapes_fixture(self, request, InlineShapes_): @@ -331,7 +331,7 @@ def it_provides_access_to_the_tables_it_contains(self, tables_fixture): ]) def add_paragraph_fixture(self, request): before_cxml, after_cxml = request.param - body = _Body(element(before_cxml)) + body = _Body(element(before_cxml), None) expected_xml = xml(after_cxml) return body, expected_xml @@ -339,7 +339,7 @@ def add_paragraph_fixture(self, request): def add_table_fixture(self, request): p_count, has_sectPr = request.param body_bldr = self._body_bldr(p_count=p_count, sectPr=has_sectPr) - body = _Body(body_bldr.element) + body = _Body(body_bldr.element, None) tbl_bldr = self._tbl_bldr() body_bldr = self._body_bldr( @@ -357,17 +357,17 @@ def add_table_fixture(self, request): ]) def clear_fixture(self, request): before_cxml, after_cxml = request.param - body = _Body(element(before_cxml)) + body = _Body(element(before_cxml), None) expected_xml = xml(after_cxml) return body, expected_xml @pytest.fixture def paragraphs_fixture(self): - return _Body(element('w:body/(w:p, w:p)')) + return _Body(element('w:body/(w:p, w:p)'), None) @pytest.fixture def tables_fixture(self): - return _Body(element('w:body/(w:tbl, w:tbl)')) + return _Body(element('w:body/(w:tbl, w:tbl)'), None) # fixture components --------------------------------------------- From bf42778316b54564db61d3e09360fd7a1f3e4281 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 16:51:04 -0700 Subject: [PATCH 155/809] tbl: base Table on Parented --- docx/parts/document.py | 4 ++-- docx/table.py | 8 ++++---- tests/test_table.py | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docx/parts/document.py b/docx/parts/document.py index 4b2542869..7e6cbc42e 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -135,7 +135,7 @@ def add_table(self, rows, cols): the main document story. """ tbl = self._body.add_tbl() - table = Table(tbl) + table = Table(tbl, self) for i in range(cols): table.add_column() for i in range(rows): @@ -161,7 +161,7 @@ def tables(self): A sequence containing all the tables in the document, in the order they appear. """ - return [Table(tbl) for tbl in self._body.tbl_lst] + return [Table(tbl, self) for tbl in self._body.tbl_lst] class InlineShapes(Parented): diff --git a/docx/table.py b/docx/table.py index a56524f83..5e1b4274a 100644 --- a/docx/table.py +++ b/docx/table.py @@ -6,16 +6,16 @@ from __future__ import absolute_import, print_function, unicode_literals -from .shared import lazyproperty, write_only_property +from .shared import lazyproperty, Parented, write_only_property from .text import Paragraph -class Table(object): +class Table(Parented): """ Proxy class for a WordprocessingML ```` element. """ - def __init__(self, tbl): - super(Table, self).__init__() + def __init__(self, tbl, parent): + super(Table, self).__init__(parent) self._tbl = tbl def add_column(self): diff --git a/tests/test_table.py b/tests/test_table.py index 2f4ff6f07..951287c13 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -65,14 +65,14 @@ def it_can_apply_a_table_style_by_name(self, table_style_set_fixture): @pytest.fixture def add_column_fixture(self): tbl = _tbl_bldr(2, 1).element - table = Table(tbl) + table = Table(tbl, None) expected_xml = _tbl_bldr(2, 2).xml() return table, expected_xml @pytest.fixture def add_row_fixture(self): tbl = _tbl_bldr(rows=1, cols=2).element - table = Table(tbl) + table = Table(tbl, None) expected_xml = _tbl_bldr(rows=2, cols=2).xml() return table, expected_xml @@ -82,7 +82,7 @@ def add_row_fixture(self): ]) def table_style_get_fixture(self, request): tbl_cxml, expected_style = request.param - table = Table(element(tbl_cxml)) + table = Table(element(tbl_cxml), None) return table, expected_style @pytest.fixture(params=[ @@ -97,7 +97,7 @@ def table_style_get_fixture(self, request): ]) def table_style_set_fixture(self, request): tbl_cxml, new_style, expected_cxml = request.param - table = Table(element(tbl_cxml)) + table = Table(element(tbl_cxml), None) expected_xml = xml(expected_cxml) return table, new_style, expected_xml @@ -106,7 +106,7 @@ def table_style_set_fixture(self, request): @pytest.fixture def table(self): tbl = _tbl_bldr(rows=2, cols=2).element - table = Table(tbl) + table = Table(tbl, None) return table From 6b717152d95d2d07683bdd6d50fc1cdc66d4f207 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 16:55:24 -0700 Subject: [PATCH 156/809] tbl: base _Columns on Parented --- docx/table.py | 8 ++++---- tests/test_table.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docx/table.py b/docx/table.py index 5e1b4274a..a2eb4e026 100644 --- a/docx/table.py +++ b/docx/table.py @@ -51,7 +51,7 @@ def columns(self): """ |_Columns| instance containing the sequence of rows in this table. """ - return _Columns(self._tbl) + return _Columns(self._tbl, self) @lazyproperty def rows(self): @@ -179,13 +179,13 @@ def _tr_lst(self): return self._tbl.tr_lst -class _Columns(object): +class _Columns(Parented): """ Sequence of |_Column| instances corresponding to the columns in a table. Supports ``len()``, iteration and indexed access. """ - def __init__(self, tbl): - super(_Columns, self).__init__() + def __init__(self, tbl, parent): + super(_Columns, self).__init__(parent) self._tbl = tbl def __getitem__(self, idx): diff --git a/tests/test_table.py b/tests/test_table.py index 951287c13..a54f0b304 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -271,7 +271,7 @@ def it_raises_on_indexed_access_out_of_range(self, columns_fixture): def columns_fixture(self): column_count = 2 tbl = _tbl_bldr(rows=2, cols=column_count).element - columns = _Columns(tbl) + columns = _Columns(tbl, None) return columns, column_count From bb8b71898450e59e15a307707f333afad44005c6 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 16:57:25 -0700 Subject: [PATCH 157/809] tbl: base _Rows on Parented --- docx/table.py | 8 ++++---- tests/test_table.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docx/table.py b/docx/table.py index a2eb4e026..323bd119a 100644 --- a/docx/table.py +++ b/docx/table.py @@ -58,7 +58,7 @@ def rows(self): """ |_Rows| instance containing the sequence of rows in this table. """ - return _Rows(self._tbl) + return _Rows(self._tbl, self) @property def style(self): @@ -258,13 +258,13 @@ def __len__(self): return len(self._tr.tc_lst) -class _Rows(object): +class _Rows(Parented): """ Sequence of |_Row| instances corresponding to the rows in a table. Supports ``len()``, iteration and indexed access. """ - def __init__(self, tbl): - super(_Rows, self).__init__() + def __init__(self, tbl, parent): + super(_Rows, self).__init__(parent) self._tbl = tbl def __getitem__(self, idx): diff --git a/tests/test_table.py b/tests/test_table.py index a54f0b304..8f716eeb5 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -356,7 +356,7 @@ def it_raises_on_indexed_access_out_of_range(self, rows_fixture): def rows_fixture(self): row_count = 2 tbl = _tbl_bldr(rows=row_count, cols=2).element - rows = _Rows(tbl) + rows = _Rows(tbl, None) return rows, row_count From 958a97df98c753c70e6902c38d5598a2b433336f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 17:06:59 -0700 Subject: [PATCH 158/809] tbl: base _Column on Parented --- docx/table.py | 13 +++++++------ tests/test_table.py | 6 +++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docx/table.py b/docx/table.py index 323bd119a..f5769c4d6 100644 --- a/docx/table.py +++ b/docx/table.py @@ -26,7 +26,7 @@ def add_column(self): gridCol = tblGrid.add_gridCol() for tr in self._tbl.tr_lst: tr.add_tc() - return _Column(gridCol, self._tbl) + return _Column(gridCol, self._tbl, self) def add_row(self): """ @@ -109,12 +109,12 @@ def text(self, text): r.text = text -class _Column(object): +class _Column(Parented): """ Table column """ - def __init__(self, gridCol, tbl): - super(_Column, self).__init__() + def __init__(self, gridCol, tbl, parent): + super(_Column, self).__init__(parent) self._gridCol = gridCol self._tbl = tbl @@ -197,10 +197,11 @@ def __getitem__(self, idx): except IndexError: msg = "column index [%d] is out of range" % idx raise IndexError(msg) - return _Column(gridCol, self._tbl) + return _Column(gridCol, self._tbl, self) def __iter__(self): - return (_Column(gridCol, self._tbl) for gridCol in self._gridCol_lst) + for gridCol in self._gridCol_lst: + yield _Column(gridCol, self._tbl, self) def __len__(self): return len(self._gridCol_lst) diff --git a/tests/test_table.py b/tests/test_table.py index 8f716eeb5..611335525 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -154,7 +154,7 @@ def paragraphs_fixture(self): class Describe_Column(object): def it_provides_access_to_the_column_cells(self): - column = _Column(None, None) + column = _Column(None, None, None) cells = column.cells assert isinstance(cells, _ColumnCells) @@ -180,7 +180,7 @@ def it_can_change_its_width(self, width_set_fixture): ]) def width_get_fixture(self, request): gridCol_cxml, expected_width = request.param - column = _Column(element(gridCol_cxml), None) + column = _Column(element(gridCol_cxml), None, None) return column, expected_width @pytest.fixture(params=[ @@ -191,7 +191,7 @@ def width_get_fixture(self, request): ]) def width_set_fixture(self, request): gridCol_cxml, new_value, expected_cxml = request.param - column = _Column(element(gridCol_cxml), None) + column = _Column(element(gridCol_cxml), None, None) expected_xml = xml(expected_cxml) return column, new_value, expected_xml From ce779e5a799b614cb5346c0f437d7e0681a24c5a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 17:11:19 -0700 Subject: [PATCH 159/809] tbl: base _Row on Parented --- docx/table.py | 12 ++++++------ tests/test_table.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docx/table.py b/docx/table.py index f5769c4d6..022528b4e 100644 --- a/docx/table.py +++ b/docx/table.py @@ -36,7 +36,7 @@ def add_row(self): tr = tbl.add_tr() for gridCol in tbl.tblGrid.gridCol_lst: tr.add_tc() - return _Row(tr) + return _Row(tr, self) def cell(self, row_idx, col_idx): """ @@ -216,12 +216,12 @@ def _gridCol_lst(self): return tblGrid.gridCol_lst -class _Row(object): +class _Row(Parented): """ Table row """ - def __init__(self, tr): - super(_Row, self).__init__() + def __init__(self, tr, parent): + super(_Row, self).__init__(parent) self._tr = tr @lazyproperty @@ -277,10 +277,10 @@ def __getitem__(self, idx): except IndexError: msg = "row index [%d] out of range" % idx raise IndexError(msg) - return _Row(tr) + return _Row(tr, self) def __iter__(self): - return (_Row(tr) for tr in self._tbl.tr_lst) + return (_Row(tr, self) for tr in self._tbl.tr_lst) def __len__(self): return len(self._tbl.tr_lst) diff --git a/tests/test_table.py b/tests/test_table.py index 611335525..5fd8cb866 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -278,7 +278,7 @@ def columns_fixture(self): class Describe_Row(object): def it_provides_access_to_the_row_cells(self): - row = _Row(element('w:tr')) + row = _Row(element('w:tr'), None) cells = row.cells assert isinstance(cells, _RowCells) From ca211caa9e9a25517d5dd4e6d77f8ec2b60db267 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 17:15:18 -0700 Subject: [PATCH 160/809] tbl: base _ColumnCells on Parented --- docx/table.py | 8 ++++---- tests/test_table.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docx/table.py b/docx/table.py index 022528b4e..bc09d90c8 100644 --- a/docx/table.py +++ b/docx/table.py @@ -124,7 +124,7 @@ def cells(self): Sequence of |_Cell| instances corresponding to cells in this column. Supports ``len()``, iteration and indexed access. """ - return _ColumnCells(self._tbl, self._gridCol) + return _ColumnCells(self._tbl, self._gridCol, self) @property def width(self): @@ -139,13 +139,13 @@ def width(self, value): self._gridCol.w = value -class _ColumnCells(object): +class _ColumnCells(Parented): """ Sequence of |_Cell| instances corresponding to the cells in a table column. """ - def __init__(self, tbl, gridCol): - super(_ColumnCells, self).__init__() + def __init__(self, tbl, gridCol, parent): + super(_ColumnCells, self).__init__(parent) self._tbl = tbl self._gridCol = gridCol diff --git a/tests/test_table.py b/tests/test_table.py index 5fd8cb866..52987b5e9 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -232,7 +232,7 @@ def cells_fixture(self): cell_count = 2 tbl = _tbl_bldr(rows=cell_count, cols=1).element gridCol = tbl.tblGrid.gridCol_lst[0] - cells = _ColumnCells(tbl, gridCol) + cells = _ColumnCells(tbl, gridCol, None) return cells, cell_count From 5cdf3e036931376ed09f684f1f74a22fd363c297 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 17:19:38 -0700 Subject: [PATCH 161/809] tbl: base _RowCells on Parented --- docx/table.py | 8 ++++---- tests/test_table.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docx/table.py b/docx/table.py index bc09d90c8..9f852a1b5 100644 --- a/docx/table.py +++ b/docx/table.py @@ -230,15 +230,15 @@ def cells(self): Sequence of |_Cell| instances corresponding to cells in this row. Supports ``len()``, iteration and indexed access. """ - return _RowCells(self._tr) + return _RowCells(self._tr, self) -class _RowCells(object): +class _RowCells(Parented): """ Sequence of |_Cell| instances corresponding to the cells in a table row. """ - def __init__(self, tr): - super(_RowCells, self).__init__() + def __init__(self, tr, parent): + super(_RowCells, self).__init__(parent) self._tr = tr def __getitem__(self, idx): diff --git a/tests/test_table.py b/tests/test_table.py index 52987b5e9..d697bceda 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -316,7 +316,7 @@ def it_raises_on_indexed_access_out_of_range(self, cell_count_fixture): @pytest.fixture def cell_count_fixture(self): - cells = _RowCells(element('w:tr/(w:tc, w:tc)')) + cells = _RowCells(element('w:tr/(w:tc, w:tc)'), None) cell_count = 2 return cells, cell_count From 54d619f1bdc1966aebc7c3af16b8ca7b49d4029c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 17:22:51 -0700 Subject: [PATCH 162/809] tbl: base _Cell on Parented --- docx/table.py | 14 +++++++------- tests/test_table.py | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docx/table.py b/docx/table.py index 9f852a1b5..9ebeb5d5d 100644 --- a/docx/table.py +++ b/docx/table.py @@ -79,12 +79,12 @@ def _tblPr(self): return self._tbl.tblPr -class _Cell(object): +class _Cell(Parented): """ Table cell """ - def __init__(self, tc): - super(_Cell, self).__init__() + def __init__(self, tc, parent): + super(_Cell, self).__init__(parent) self._tc = tc @property @@ -159,12 +159,12 @@ def __getitem__(self, idx): msg = "cell index [%d] is out of range" % idx raise IndexError(msg) tc = tr.tc_lst[self._col_idx] - return _Cell(tc) + return _Cell(tc, self) def __iter__(self): for tr in self._tr_lst: tc = tr.tc_lst[self._col_idx] - yield _Cell(tc) + yield _Cell(tc, self) def __len__(self): return len(self._tr_lst) @@ -250,10 +250,10 @@ def __getitem__(self, idx): except IndexError: msg = "cell index [%d] is out of range" % idx raise IndexError(msg) - return _Cell(tc) + return _Cell(tc, self) def __iter__(self): - return (_Cell(tc) for tc in self._tr.tc_lst) + return (_Cell(tc, self) for tc in self._tr.tc_lst) def __len__(self): return len(self._tr.tc_lst) diff --git a/tests/test_table.py b/tests/test_table.py index d697bceda..4bfe4416d 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -142,13 +142,13 @@ def it_can_replace_its_content_with_a_string_of_text( ]) def text_set_fixture(self, request): tc_cxml, new_text, expected_cxml = request.param - cell = _Cell(element(tc_cxml)) + cell = _Cell(element(tc_cxml), None) expected_xml = xml(expected_cxml) return cell, new_text, expected_xml @pytest.fixture def paragraphs_fixture(self): - return _Cell(element('w:tc/(w:p, w:p)')) + return _Cell(element('w:tc/(w:p, w:p)'), None) class Describe_Column(object): From f01a873b4e882f8a4f4807e8580132fe764e41e5 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 17:32:35 -0700 Subject: [PATCH 163/809] txt: base Paragraph on Parented --- docx/parts/document.py | 4 ++-- docx/table.py | 2 +- docx/text.py | 11 ++++++----- features/steps/paragraph.py | 2 +- tests/test_text.py | 20 ++++++++++---------- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/docx/parts/document.py b/docx/parts/document.py index 7e6cbc42e..dea64464a 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -127,7 +127,7 @@ def add_paragraph(self): Return a paragraph newly added to the end of body content. """ p = self._body.add_p() - return Paragraph(p) + return Paragraph(p, self) def add_table(self, rows, cols): """ @@ -153,7 +153,7 @@ def clear_content(self): @property def paragraphs(self): - return [Paragraph(p) for p in self._body.p_lst] + return [Paragraph(p, self) for p in self._body.p_lst] @property def tables(self): diff --git a/docx/table.py b/docx/table.py index 9ebeb5d5d..9be04b0d5 100644 --- a/docx/table.py +++ b/docx/table.py @@ -94,7 +94,7 @@ def paragraphs(self): at least one block-level element. By default this is a single paragraph. """ - return [Paragraph(p) for p in self._tc.p_lst] + return [Paragraph(p, self) for p in self._tc.p_lst] @write_only_property def text(self, text): diff --git a/docx/text.py b/docx/text.py index a221fde99..2b2074be1 100644 --- a/docx/text.py +++ b/docx/text.py @@ -6,7 +6,8 @@ from __future__ import absolute_import, print_function, unicode_literals -from docx.enum.text import WD_BREAK +from .enum.text import WD_BREAK +from .shared import Parented def boolproperty(f): @@ -54,12 +55,12 @@ def setter(obj, value): return property(getter, setter, doc=f.__doc__) -class Paragraph(object): +class Paragraph(Parented): """ Proxy object wrapping ```` element. """ - def __init__(self, p): - super(Paragraph, self).__init__() + def __init__(self, p, parent): + super(Paragraph, self).__init__(parent) self._p = p def add_run(self, text=None, style=None): @@ -110,7 +111,7 @@ def insert_paragraph_before(self, text=None, style=None): to the new paragraph. """ p = self._p.add_p_before() - paragraph = Paragraph(p) + paragraph = Paragraph(p, self._parent) if text: paragraph.add_run(text) if style is not None: diff --git a/features/steps/paragraph.py b/features/steps/paragraph.py index 6f106b862..cdd79bde6 100644 --- a/features/steps/paragraph.py +++ b/features/steps/paragraph.py @@ -54,7 +54,7 @@ def given_a_paragraph_with_content_and_formatting(context): """ % (nsdecls('w'), TEST_STYLE) p = parse_xml(p_xml) - context.paragraph = Paragraph(p) + context.paragraph = Paragraph(p, None) # when ==================================================== diff --git a/tests/test_text.py b/tests/test_text.py index ec80d37e3..3c15b55d9 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -90,7 +90,7 @@ def it_can_remove_its_content_while_preserving_formatting( ]) def add_run_fixture(self, request): before_cxml, text, style, after_cxml = request.param - paragraph = Paragraph(element(before_cxml)) + paragraph = Paragraph(element(before_cxml), None) expected_xml = xml(after_cxml) return paragraph, text, style, expected_xml @@ -100,7 +100,7 @@ def add_run_fixture(self, request): ]) def alignment_get_fixture(self, request): cxml, expected_alignment_value = request.param - paragraph = Paragraph(element(cxml)) + paragraph = Paragraph(element(cxml), None) return paragraph, expected_alignment_value @pytest.fixture(params=[ @@ -114,7 +114,7 @@ def alignment_get_fixture(self, request): ]) def alignment_set_fixture(self, request): initial_cxml, new_alignment_value, expected_cxml = request.param - paragraph = Paragraph(element(initial_cxml)) + paragraph = Paragraph(element(initial_cxml), None) expected_xml = xml(expected_cxml) return paragraph, new_alignment_value, expected_xml @@ -126,7 +126,7 @@ def alignment_set_fixture(self, request): ]) def clear_fixture(self, request): initial_cxml, expected_cxml = request.param - paragraph = Paragraph(element(initial_cxml)) + paragraph = Paragraph(element(initial_cxml), None) expected_xml = xml(expected_cxml) return paragraph, expected_xml @@ -137,13 +137,13 @@ def clear_fixture(self, request): def insert_before_fixture(self, request): body_cxml, text, style, expected_cxml = request.param body = element(body_cxml) - paragraph = Paragraph(body.find(qn('w:p'))) + paragraph = Paragraph(body.find(qn('w:p')), None) expected_xml = xml(expected_cxml) return paragraph, text, style, body, expected_xml @pytest.fixture def runs_fixture(self, p_, Run_, r_, r_2_, runs_): - paragraph = Paragraph(p_) + paragraph = Paragraph(p_, None) run_, run_2_ = runs_ return paragraph, Run_, r_, r_2_, run_, run_2_ @@ -154,7 +154,7 @@ def runs_fixture(self, p_, Run_, r_, r_2_, runs_): ]) def style_get_fixture(self, request): p_cxml, expected_style = request.param - paragraph = Paragraph(element(p_cxml)) + paragraph = Paragraph(element(p_cxml), None) return paragraph, expected_style @pytest.fixture(params=[ @@ -171,7 +171,7 @@ def style_get_fixture(self, request): ]) def style_set_fixture(self, request): p_cxml, new_style_value, expected_cxml = request.param - paragraph = Paragraph(element(p_cxml)) + paragraph = Paragraph(element(p_cxml), None) expected_xml = xml(expected_cxml) return paragraph, new_style_value, expected_xml @@ -188,12 +188,12 @@ def style_set_fixture(self, request): ]) def text_get_fixture(self, request): p_cxml, expected_text_value = request.param - paragraph = Paragraph(element(p_cxml)) + paragraph = Paragraph(element(p_cxml), None) return paragraph, expected_text_value @pytest.fixture def text_set_fixture(self): - paragraph = Paragraph(element('w:p')) + 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' From d77054ab1cccc70a13661c243f1ad3c5b9940a81 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 17:39:04 -0700 Subject: [PATCH 164/809] txt: base Run on Parented Remove stray print() call left in during testing. --- docx/table.py | 1 - docx/text.py | 10 +++++----- features/steps/text.py | 2 +- tests/test_text.py | 30 ++++++++++++++++-------------- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/docx/table.py b/docx/table.py index 9be04b0d5..eb7b62992 100644 --- a/docx/table.py +++ b/docx/table.py @@ -71,7 +71,6 @@ def style(self): @style.setter def style(self, value): - print('style value is %s' % value) self._tblPr.style = value @property diff --git a/docx/text.py b/docx/text.py index 2b2074be1..b720fdb96 100644 --- a/docx/text.py +++ b/docx/text.py @@ -73,7 +73,7 @@ def add_run(self, text=None, style=None): break. """ r = self._p.add_r() - run = Run(r) + run = Run(r, self) if text: run.text = text if style: @@ -124,7 +124,7 @@ def runs(self): Sequence of |Run| instances corresponding to the elements in this paragraph. """ - return [Run(r) for r in self._p.r_lst] + return [Run(r, self) for r in self._p.r_lst] @property def style(self): @@ -163,7 +163,7 @@ def text(self, text): self.add_run(text) -class Run(object): +class Run(Parented): """ Proxy object wrapping ```` element. Several of the properties on Run take a tri-state value, |True|, |False|, or |None|. |True| and |False| @@ -171,8 +171,8 @@ class Run(object): not specified directly on the run and its effective value is taken from the style hierarchy. """ - def __init__(self, r): - super(Run, self).__init__() + def __init__(self, r, parent): + super(Run, self).__init__(parent) self._r = r def add_break(self, break_type=WD_BREAK.LINE): diff --git a/features/steps/text.py b/features/steps/text.py index b1a003042..11852d91f 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -57,7 +57,7 @@ def given_a_run_having_mixed_text_content(context): jkl """ % nsdecls('w') r = parse_xml(r_xml) - context.run = Run(r) + context.run = Run(r, None) @given('a run having {underline_type} underline') diff --git a/tests/test_text.py b/tests/test_text.py index 3c15b55d9..00b488388 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -24,7 +24,9 @@ class DescribeParagraph(object): 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_), call(r_2_)] + 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): @@ -308,7 +310,7 @@ def it_can_replace_the_text_it_contains(self, text_set_fixture): ]) def add_break_fixture(self, request): break_type, expected_cxml = request.param - run = Run(element('w:r')) + run = Run(element('w:r'), None) expected_xml = xml(expected_cxml) return run, break_type, expected_xml @@ -317,7 +319,7 @@ def add_break_fixture(self, request): ]) def add_tab_fixture(self, request): r_cxml, expected_cxml = request.param - run = Run(element(r_cxml)) + run = Run(element(r_cxml), None) expected_xml = xml(expected_cxml) return run, expected_xml @@ -329,7 +331,7 @@ def add_tab_fixture(self, request): ]) def add_text_fixture(self, request, Text_): r_cxml, text, expected_cxml = request.param - run = Run(element(r_cxml)) + run = Run(element(r_cxml), None) expected_xml = xml(expected_cxml) return run, text, expected_xml, Text_ @@ -361,7 +363,7 @@ def add_text_fixture(self, request, Text_): ]) def bool_prop_get_fixture(self, request): r_cxml, bool_prop_name, expected_value = request.param - run = Run(element(r_cxml)) + run = Run(element(r_cxml), None) return run, bool_prop_name, expected_value @pytest.fixture(params=[ @@ -415,7 +417,7 @@ def bool_prop_get_fixture(self, request): ]) def bool_prop_set_fixture(self, request): initial_r_cxml, bool_prop_name, value, expected_cxml = request.param - run = Run(element(initial_r_cxml)) + run = Run(element(initial_r_cxml), None) expected_xml = xml(expected_cxml) return run, bool_prop_name, value, expected_xml @@ -430,7 +432,7 @@ def bool_prop_set_fixture(self, request): ]) def clear_fixture(self, request): initial_r_cxml, expected_cxml = request.param - run = Run(element(initial_r_cxml)) + run = Run(element(initial_r_cxml), None) expected_xml = xml(expected_cxml) return run, expected_xml @@ -440,7 +442,7 @@ def clear_fixture(self, request): ]) def style_get_fixture(self, request): r_cxml, expected_style = request.param - run = Run(element(r_cxml)) + run = Run(element(r_cxml), None) return run, expected_style @pytest.fixture(params=[ @@ -455,7 +457,7 @@ def style_get_fixture(self, request): ]) def style_set_fixture(self, request): initial_r_cxml, new_style, expected_cxml = request.param - run = Run(element(initial_r_cxml)) + run = Run(element(initial_r_cxml), None) expected_xml = xml(expected_cxml) return run, new_style, expected_xml @@ -467,7 +469,7 @@ def style_set_fixture(self, request): ]) def text_get_fixture(self, request): r_cxml, expected_text = request.param - run = Run(element(r_cxml)) + run = Run(element(r_cxml), None) return run, expected_text @pytest.fixture(params=[ @@ -479,7 +481,7 @@ def text_get_fixture(self, request): def text_set_fixture(self, request): new_text, expected_cxml = request.param initial_r_cxml = 'w:r/w:t"should get deleted"' - run = Run(element(initial_r_cxml)) + run = Run(element(initial_r_cxml), None) expected_xml = xml(expected_cxml) return run, new_text, expected_xml @@ -493,7 +495,7 @@ def text_set_fixture(self, request): ]) def underline_get_fixture(self, request): r_cxml, expected_underline = request.param - run = Run(element(r_cxml)) + run = Run(element(r_cxml), None) return run, expected_underline @pytest.fixture(params=[ @@ -515,14 +517,14 @@ def underline_get_fixture(self, request): ]) def underline_set_fixture(self, request): initial_r_cxml, new_underline, expected_cxml = request.param - run = Run(element(initial_r_cxml)) + run = Run(element(initial_r_cxml), None) expected_xml = xml(expected_cxml) return run, new_underline, expected_xml @pytest.fixture(params=['foobar', 42, 'single']) def underline_raise_fixture(self, request): invalid_underline_setting = request.param - run = Run(element('w:r/w:rPr')) + run = Run(element('w:r/w:rPr'), None) return run, invalid_underline_setting # fixture components --------------------------------------------- From 7df532354a2b506753cef30d40df203c07a0dd51 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 18:20:53 -0700 Subject: [PATCH 165/809] shp: refactor InlineShapes.add_picture() Key change is adding *run* parameter to InlineShapes.add_picture() rather than adding a run to the document in the method, so picture can be added to an arbitrary run. --- docx/api.py | 3 ++- docx/parts/document.py | 15 ++++++++------- features/steps/shape.py | 6 ++++-- tests/parts/test_document.py | 11 ++++++----- tests/test_api.py | 4 ++-- tests/unitutil/mock.py | 2 +- 6 files changed, 23 insertions(+), 18 deletions(-) diff --git a/docx/api.py b/docx/api.py index d1f11ed06..2fb936acf 100644 --- a/docx/api.py +++ b/docx/api.py @@ -87,7 +87,8 @@ def add_picture(self, image_path_or_stream, width=None, height=None): (dpi) value specified in the image file, defaulting to 72 dpi if no value is specified, as is often the case. """ - picture = self.inline_shapes.add_picture(image_path_or_stream) + run = self.add_paragraph().add_run() + picture = self.inline_shapes.add_picture(image_path_or_stream, run) # scale picture dimensions if width and/or height provided if width is not None or height is not None: diff --git a/docx/parts/document.py b/docx/parts/document.py index dea64464a..947c4bb26 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -190,17 +190,18 @@ def __iter__(self): def __len__(self): return len(self._inline_lst) - def add_picture(self, image_descriptor): + def add_picture(self, image_descriptor, run): """ - Add the image identified by *image_descriptor* to the document at its - native size. The picture is placed inline in a new paragraph at the - end of the document. *image_descriptor* can be a path (a string) or a - file-like object containing a binary image. + Return an |InlineShape| instance containing the picture identified by + *image_descriptor* and added to the end of *run*. The picture shape + has the native size of the image. *image_descriptor* can be a path (a + string) or a file-like object containing a binary image. """ image_part, rId = self.part.get_or_add_image_part(image_descriptor) shape_id = self.part.next_id - r = self._body.add_p().add_r() - return InlineShape.new_picture(r, image_part, rId, shape_id) + r = run._r + picture = InlineShape.new_picture(r, image_part, rId, shape_id) + return picture @property def _inline_lst(self): diff --git a/features/steps/shape.py b/features/steps/shape.py index 10a4e3f3a..6d0cab1a8 100644 --- a/features/steps/shape.py +++ b/features/steps/shape.py @@ -58,15 +58,17 @@ def given_inline_shape_known_to_be_shape_of_type(context, shp_of_type): @when('I add an inline picture from a file-like object') def when_add_inline_picture_from_file_like_object(context): document = context.document + run = document.add_paragraph().add_run() with open(test_file_path('monty-truth.png'), 'rb') as f: - context.inline_shape = document.inline_shapes.add_picture(f) + context.inline_shape = document.inline_shapes.add_picture(f, run) @when('I add an inline picture to the document') def when_add_inline_picture_to_document(context): document = context.document + run = document.add_paragraph().add_run() context.inline_shape = (document.inline_shapes.add_picture( - test_file_path('monty-truth.png') + test_file_path('monty-truth.png'), run )) diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 14f1969a7..55b7b8aab 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -18,7 +18,7 @@ from docx.section import Section from docx.shape import InlineShape from docx.table import Table -from docx.text import Paragraph +from docx.text import Paragraph, Run from ..oxml.parts.unitdata.document import a_body, a_document from ..oxml.unitdata.table import ( @@ -448,10 +448,10 @@ def it_can_add_an_inline_picture_to_the_document( self, add_picture_fixture): # fixture ---------------------- (inline_shapes, image_descriptor_, document_, InlineShape_, - r_, image_part_, rId_, shape_id_, new_picture_shape_ + run, r_, image_part_, rId_, shape_id_, new_picture_shape_ ) = add_picture_fixture # exercise --------------------- - picture_shape = inline_shapes.add_picture(image_descriptor_) + picture_shape = inline_shapes.add_picture(image_descriptor_, run) # verify ----------------------- document_.get_or_add_image_part.assert_called_once_with( image_descriptor_ @@ -474,9 +474,10 @@ def add_picture_fixture( r_, image_part_, rId_, shape_id_, new_picture_shape_): inline_shapes = InlineShapes(body_, None) property_mock(request, InlineShapes, 'part', return_value=document_) + run = Run(r_, None) return ( - inline_shapes, image_descriptor_, document_, InlineShape_, r_, - image_part_, rId_, shape_id_, new_picture_shape_ + inline_shapes, image_descriptor_, document_, InlineShape_, run, + r_, image_part_, rId_, shape_id_, new_picture_shape_ ) @pytest.fixture diff --git a/tests/test_api.py b/tests/test_api.py index dfc759c38..8dd28cc93 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -22,7 +22,7 @@ from docx.text import Paragraph, Run from .unitutil.mock import ( - instance_mock, class_mock, method_mock, property_mock, var_mock + ANY, instance_mock, class_mock, method_mock, property_mock, var_mock ) @@ -93,7 +93,7 @@ def it_can_add_a_picture(self, add_picture_fixture): (document, image_path, width, height, inline_shapes_, expected_width, expected_height, picture_) = add_picture_fixture picture = document.add_picture(image_path, width, height) - inline_shapes_.add_picture.assert_called_once_with(image_path) + inline_shapes_.add_picture.assert_called_once_with(image_path, ANY) assert picture.width == expected_width assert picture.height == expected_height assert picture is picture_ diff --git a/tests/unitutil/mock.py b/tests/unitutil/mock.py index aa3d8f2ef..0893833ff 100644 --- a/tests/unitutil/mock.py +++ b/tests/unitutil/mock.py @@ -14,7 +14,7 @@ from unittest.mock import create_autospec, Mock, patch, PropertyMock else: import mock # noqa - from mock import call, MagicMock # noqa + from mock import ANY, call, MagicMock # noqa from mock import create_autospec, Mock, patch, PropertyMock From ff0bd9115b59c26f08841b76dfd2a0f9ecbe9687 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 20:42:49 -0700 Subject: [PATCH 166/809] acpt: clean up picture-related test steps * rename helpers.test_file_path to test_file * move picture-related assertions to steps/shape.py * refactor given_a_run() --- features/run-add-content.feature | 2 -- features/steps/api.py | 41 ++++---------------------------- features/steps/helpers.py | 2 +- features/steps/image.py | 4 ++-- features/steps/shape.py | 37 +++++++++++++++++++++++++--- features/steps/text.py | 5 ++-- 6 files changed, 45 insertions(+), 46 deletions(-) diff --git a/features/run-add-content.feature b/features/run-add-content.feature index 4a3cb78a0..d4257925c 100644 --- a/features/run-add-content.feature +++ b/features/run-add-content.feature @@ -3,13 +3,11 @@ Feature: Add content to a run As a developer using python-docx I need a way to add each of the run content elements to a run - Scenario: Add a tab Given a run When I add a tab Then the tab appears at the end of the run - Scenario: Assign mixed text to text property Given a run When I assign mixed text to the text property diff --git a/features/steps/api.py b/features/steps/api.py index 05fdfad9d..96363b0a4 100644 --- a/features/steps/api.py +++ b/features/steps/api.py @@ -9,7 +9,7 @@ from docx.shared import Inches from docx.table import Table -from helpers import test_file_path +from helpers import test_file # when ==================================================== @@ -70,7 +70,7 @@ 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_path('monty-truth.png'), + test_file('monty-truth.png'), width=Inches(1.75), height=Inches(2.5) ) @@ -79,7 +79,7 @@ def when_add_picture_specifying_width_and_height(context): def when_add_picture_specifying_height(context): document = context.document context.picture = document.add_picture( - test_file_path('monty-truth.png'), height=Inches(1.5) + test_file('monty-truth.png'), height=Inches(1.5) ) @@ -87,14 +87,14 @@ def when_add_picture_specifying_height(context): def when_add_picture_specifying_width(context): document = context.document context.picture = document.add_picture( - test_file_path('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') def when_add_picture_specifying_only_image_file(context): document = context.document - context.picture = document.add_picture(test_file_path('monty-truth.png')) + context.picture = document.add_picture(test_file('monty-truth.png')) # then ===================================================== @@ -149,37 +149,6 @@ def then_last_p_is_empty_paragraph_added(context): assert p.text == '' -@then('the picture has its native width and height') -def then_picture_has_native_width_and_height(context): - picture = context.picture - assert picture.width == 1905000, 'got %d' % picture.width - assert picture.height == 2717800, 'got %d' % picture.height - - -@then('the picture height is 2.14 inches') -def then_picture_height_is_value_2(context): - picture = context.picture - assert picture.height == 1956816, 'got %d' % picture.height - - -@then('the picture height is 2.5 inches') -def then_picture_height_is_value(context): - picture = context.picture - assert picture.height == 2286000, 'got %d' % picture.height - - -@then('the picture width is 1.05 inches') -def then_picture_width_is_value_2(context): - picture = context.picture - assert picture.width == 961402, 'got %d' % picture.width - - -@then('the picture width is 1.75 inches') -def then_picture_width_is_value(context): - picture = context.picture - assert picture.width == 1600200, 'got %d' % picture.width - - @then('the style of the last paragraph is \'{style}\'') def then_style_of_last_paragraph_is_style(context, style): document = context.document diff --git a/features/steps/helpers.py b/features/steps/helpers.py index 1619fbd49..44bfec5ba 100644 --- a/features/steps/helpers.py +++ b/features/steps/helpers.py @@ -26,7 +26,7 @@ def test_docx(name): return absjoin(thisdir, 'test_files', '%s.docx' % name) -def test_file_path(name): +def test_file(name): """ Return the absolute path to file with *name* in test_files directory """ diff --git a/features/steps/image.py b/features/steps/image.py index 6539d8c8d..ee2a35c17 100644 --- a/features/steps/image.py +++ b/features/steps/image.py @@ -10,14 +10,14 @@ from docx.image.image import Image -from helpers import test_file_path +from helpers import test_file # given =================================================== @given('the image file \'{filename}\'') def given_image_filename(context, filename): - context.image_path = test_file_path(filename) + context.image_path = test_file(filename) # when ==================================================== diff --git a/features/steps/shape.py b/features/steps/shape.py index 6d0cab1a8..ec8c427a2 100644 --- a/features/steps/shape.py +++ b/features/steps/shape.py @@ -15,7 +15,7 @@ from docx.parts.document import InlineShape, InlineShapes from docx.shared import Inches -from helpers import test_docx, test_file_path +from helpers import test_docx, test_file # given =================================================== @@ -59,7 +59,7 @@ def given_inline_shape_known_to_be_shape_of_type(context, shp_of_type): def when_add_inline_picture_from_file_like_object(context): document = context.document run = document.add_paragraph().add_run() - with open(test_file_path('monty-truth.png'), 'rb') as f: + with open(test_file('monty-truth.png'), 'rb') as f: context.inline_shape = document.inline_shapes.add_picture(f, run) @@ -68,7 +68,7 @@ def when_add_inline_picture_to_document(context): document = context.document run = document.add_paragraph().add_run() context.inline_shape = (document.inline_shapes.add_picture( - test_file_path('monty-truth.png'), run + test_file('monty-truth.png'), run )) @@ -155,3 +155,34 @@ def then_len_of_inline_shape_collection_is_5(context): inline_shapes = context.document.inline_shapes shape_count = len(inline_shapes) assert shape_count == 5, 'got %s' % shape_count + + +@then('the picture has its native width and height') +def then_picture_has_native_width_and_height(context): + picture = context.picture + assert picture.width == 1905000, 'got %d' % picture.width + assert picture.height == 2717800, 'got %d' % picture.height + + +@then('the picture height is 2.14 inches') +def then_picture_height_is_value_2(context): + picture = context.picture + assert picture.height == 1956816, 'got %d' % picture.height + + +@then('the picture height is 2.5 inches') +def then_picture_height_is_value(context): + picture = context.picture + assert picture.height == 2286000, 'got %d' % picture.height + + +@then('the picture width is 1.05 inches') +def then_picture_width_is_value_2(context): + picture = context.picture + assert picture.width == 961402, 'got %d' % picture.width + + +@then('the picture width is 1.75 inches') +def then_picture_width_is_value(context): + picture = context.picture + assert picture.width == 1600200, 'got %d' % picture.width diff --git a/features/steps/text.py b/features/steps/text.py index 11852d91f..bd4308a2b 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -21,8 +21,9 @@ @given('a run') def given_a_run(context): - p = Document().add_paragraph() - context.run = p.add_run() + document = Document() + run = document.add_paragraph().add_run() + context.run = run @given('a run having {bool_prop_name} set on') From 6ac7cedb5f55601213071b9541c9bddf5e22dd54 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 20:48:19 -0700 Subject: [PATCH 167/809] acpt: add run-add-picture.feature --- features/run-add-picture.feature | 25 +++++++++++++++++++ features/steps/text.py | 43 +++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 features/run-add-picture.feature diff --git a/features/run-add-picture.feature b/features/run-add-picture.feature new file mode 100644 index 000000000..a9ce308a4 --- /dev/null +++ b/features/run-add-picture.feature @@ -0,0 +1,25 @@ +Feature: Add picture to a run + In order to place an inline picture at an arbitrary place in a document + As a developer using python-docx + I need a way to add a picture to a run + + @wip + Scenario: Add a picture to a body paragraph run + Given a run + When I add a picture to the run + Then the picture appears at the end of the run + And the document contains the inline picture + + + @wip + Scenario Outline: Add a picture to a run in a table cell + Given a run inside a table cell retrieved from + When I add a picture to the run + Then the picture appears at the end of the run + And the document contains the inline picture + + Examples: Table cell sources + | cell-source | + | Table.cell | + | Table.row.cells | + | Table.column.cells | diff --git a/features/steps/text.py b/features/steps/text.py index bd4308a2b..0d9afe97e 100644 --- a/features/steps/text.py +++ b/features/steps/text.py @@ -6,6 +6,8 @@ from __future__ import absolute_import, print_function, unicode_literals +import hashlib + from behave import given, then, when from docx import Document @@ -14,7 +16,7 @@ from docx.oxml.ns import nsdecls, qn from docx.text import Run -from helpers import test_docx, test_text +from helpers import test_docx, test_file, test_text # given =================================================== @@ -23,6 +25,7 @@ def given_a_run(context): document = Document() run = document.add_paragraph().add_run() + context.document = document context.run = run @@ -79,6 +82,21 @@ def given_a_run_having_style_char_style(context, char_style): context.run = document.paragraphs[0].runs[run_idx] +@given('a run inside a table cell retrieved from {cell_source}') +def given_a_run_inside_a_table_cell_from_source(context, cell_source): + document = Document() + table = document.add_table(rows=2, cols=2) + if cell_source == 'Table.cell': + cell = table.cell(0, 0) + elif cell_source == 'Table.row.cells': + cell = table.rows[0].cells[1] + elif cell_source == 'Table.column.cells': + cell = table.columns[1].cells[0] + run = cell.paragraphs[0].add_run() + context.document = document + context.run = run + + # when ==================================================== @when('I add a column break') @@ -99,6 +117,12 @@ def when_add_page_break(context): run.add_break(WD_BREAK.PAGE) +@when('I add a picture to the run') +def when_I_add_a_picture_to_the_run(context): + run = context.run + run.add_picture(test_file('monty-truth.png')) + + @when('I add a run specifying its text') def when_I_add_a_run_specifying_its_text(context): context.run = context.paragraph.add_run(test_text) @@ -184,6 +208,23 @@ def then_last_item_in_run_is_a_break(context): assert context.last_child.tag == expected_tag +@then('the picture appears at the end of the run') +def then_the_picture_appears_at_the_end_of_the_run(context): + run = context.run + r = run._r + blip_rId = r.xpath( + './w:drawing/wp:inline/a:graphic/a:graphicData/pic:pic/pic:blipFill/' + 'a:blip/@r:embed' + )[0] + image_part = run.part.related_parts[blip_rId] + image_sha1 = hashlib.sha1(image_part.blob).hexdigest() + expected_sha1 = '79769f1e202add2e963158b532e36c2c0f76a70c' + assert image_sha1 == expected_sha1, ( + "image SHA1 doesn't match, expected %s, got %s" % + (expected_sha1, image_sha1) + ) + + @then('the run appears in {boolean_prop_name} unconditionally') def then_run_appears_in_boolean_prop_name(context, boolean_prop_name): run = context.run From d7001edba3fd9997cc45cb706bbfcfe0e343e9ee Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 20:53:09 -0700 Subject: [PATCH 168/809] txt: add Run.add_picture() --- docx/parts/document.py | 2 +- docx/text.py | 31 ++++++++++++++++++++++ features/run-add-picture.feature | 2 -- tests/test_text.py | 45 ++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 3 deletions(-) diff --git a/docx/parts/document.py b/docx/parts/document.py index 947c4bb26..5253d714c 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -206,7 +206,7 @@ def add_picture(self, image_descriptor, run): @property def _inline_lst(self): body = self._body - xpath = './w:p/w:r/w:drawing/wp:inline' + xpath = '//w:p/w:r/w:drawing/wp:inline' return body.xpath(xpath) diff --git a/docx/text.py b/docx/text.py index b720fdb96..0c551beeb 100644 --- a/docx/text.py +++ b/docx/text.py @@ -196,6 +196,37 @@ def add_break(self, break_type=WD_BREAK.LINE): if clear is not None: br.clear = clear + def add_picture(self, image_path_or_stream, width=None, height=None): + """ + Return an |InlineShape| instance containing the image identified by + *image_path_or_stream*, added to the end of this run. + *image_path_or_stream* can be a path (a string) or a file-like object + containing a binary image. If neither width nor height is specified, + the picture appears at its native size. If only one is specified, it + is used to compute a scaling factor that is then applied to the + unspecified dimension, preserving the aspect ratio of the image. The + native size of the picture is calculated using the dots-per-inch + (dpi) value specified in the image file, defaulting to 72 dpi if no + value is specified, as is often the case. + """ + inline_shapes = self.part.inline_shapes + picture = inline_shapes.add_picture(image_path_or_stream, self) + + # scale picture dimensions if width and/or height provided + if width is not None or height is not None: + native_width, native_height = picture.width, picture.height + if width is None: + scaling_factor = float(height) / float(native_height) + width = int(round(native_width * scaling_factor)) + elif height is None: + scaling_factor = float(width) / float(native_width) + height = int(round(native_height * scaling_factor)) + # set picture to scaled dimensions + picture.width = width + picture.height = height + + return picture + def add_tab(self): """ Add a ```` element at the end of the run, which Word diff --git a/features/run-add-picture.feature b/features/run-add-picture.feature index a9ce308a4..d0c592d3b 100644 --- a/features/run-add-picture.feature +++ b/features/run-add-picture.feature @@ -3,7 +3,6 @@ Feature: Add picture to a run As a developer using python-docx I need a way to add a picture to a run - @wip Scenario: Add a picture to a body paragraph run Given a run When I add a picture to the run @@ -11,7 +10,6 @@ Feature: Add picture to a run And the document contains the inline picture - @wip Scenario Outline: Add a picture to a run in a table cell Given a run inside a table cell retrieved from When I add a picture to the run diff --git a/tests/test_text.py b/tests/test_text.py index 00b488388..a39a3ed5f 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -11,6 +11,8 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_UNDERLINE from docx.oxml.ns import qn from docx.oxml.text import CT_P, CT_R +from docx.parts.document import InlineShapes +from docx.shape import InlineShape from docx.text import Paragraph, Run import pytest @@ -280,6 +282,17 @@ def it_can_add_a_tab(self, add_tab_fixture): run.add_tab() assert run._r.xml == expected_xml + def it_can_add_a_picture(self, add_picture_fixture): + (run, image_descriptor_, width, height, inline_shapes_, + expected_width, expected_height, picture_) = add_picture_fixture + picture = run.add_picture(image_descriptor_, width, height) + inline_shapes_.add_picture.assert_called_once_with( + image_descriptor_, run + ) + assert picture is picture_ + assert picture.width == expected_width + assert picture.height == expected_height + def it_can_remove_its_content_but_keep_formatting(self, clear_fixture): run, expected_xml = clear_fixture _run = run.clear() @@ -314,6 +327,24 @@ def add_break_fixture(self, request): expected_xml = xml(expected_cxml) return run, break_type, expected_xml + @pytest.fixture(params=[ + (None, None, 200, 100), + (1000, 500, 1000, 500), + (2000, None, 2000, 1000), + (None, 2000, 4000, 2000), + ]) + def add_picture_fixture( + self, request, paragraph_, inline_shapes_, picture_): + width, height, expected_width, expected_height = request.param + paragraph_.part.inline_shapes = inline_shapes_ + run = Run(None, paragraph_) + image_descriptor_ = 'image_descriptor_' + picture_.width, picture_.height = 200, 100 + return ( + run, image_descriptor_, width, height, inline_shapes_, + expected_width, expected_height, picture_ + ) + @pytest.fixture(params=[ ('w:r/w:t"foo"', 'w:r/(w:t"foo", w:tab)'), ]) @@ -529,6 +560,20 @@ def underline_raise_fixture(self, request): # fixture components --------------------------------------------- + @pytest.fixture + def inline_shapes_(self, request, picture_): + inline_shapes_ = instance_mock(request, InlineShapes) + inline_shapes_.add_picture.return_value = picture_ + return inline_shapes_ + + @pytest.fixture + def paragraph_(self, request): + return instance_mock(request, Paragraph) + + @pytest.fixture + def picture_(self, request): + return instance_mock(request, InlineShape) + @pytest.fixture def Text_(self, request): return class_mock(request, 'docx.text.Text') From 158a78e6443e5f9e6858b7500a782a013e65ab73 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 20:56:00 -0700 Subject: [PATCH 169/809] doc: refactor Document.add_picture() Use Run.add_picture() to do the heavy lifting. --- docx/api.py | 34 ++++++++++--------------------- tests/test_api.py | 51 +++++++++++++++++++++-------------------------- 2 files changed, 34 insertions(+), 51 deletions(-) diff --git a/docx/api.py b/docx/api.py index 2fb936acf..146786170 100644 --- a/docx/api.py +++ b/docx/api.py @@ -78,31 +78,19 @@ def add_paragraph(self, text='', style=None): def add_picture(self, image_path_or_stream, width=None, height=None): """ - Add the image at *image_path_or_stream* in a new paragraph at the end - of the document. If neither width nor height is specified, the - picture appears at its native size. If only one is specified, it is - used to compute a scaling factor that is then applied to the - unspecified dimension, preserving the aspect ratio of the image. The - native size of the picture is calculated using the dots-per-inch - (dpi) value specified in the image file, defaulting to 72 dpi if no - value is specified, as is often the case. + Return a new picture shape added in its own paragraph at the end of + the document. The picture contains the image at + *image_path_or_stream*, scaled based on *width* and *height*. If + neither width nor height is specified, the picture appears at its + native size. If only one is specified, it is used to compute + a scaling factor that is then applied to the unspecified dimension, + preserving the aspect ratio of the image. The native size of the + picture is calculated using the dots-per-inch (dpi) value specified + in the image file, defaulting to 72 dpi if no value is specified, as + is often the case. """ run = self.add_paragraph().add_run() - picture = self.inline_shapes.add_picture(image_path_or_stream, run) - - # scale picture dimensions if width and/or height provided - if width is not None or height is not None: - native_width, native_height = picture.width, picture.height - if width is None: - scaling_factor = float(height) / float(native_height) - width = int(round(native_width * scaling_factor)) - elif height is None: - scaling_factor = float(width) / float(native_width) - height = int(round(native_height * scaling_factor)) - # set picture to scaled dimensions - picture.width = width - picture.height = height - + picture = run.add_picture(image_path_or_stream, width, height) return picture def add_section(self, start_type=WD_SECTION.NEW_PAGE): diff --git a/tests/test_api.py b/tests/test_api.py index 8dd28cc93..189fea09b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,11 +18,12 @@ from docx.parts.numbering import NumberingPart from docx.parts.styles import StylesPart from docx.section import Section +from docx.shape import InlineShape from docx.table import Table from docx.text import Paragraph, Run from .unitutil.mock import ( - ANY, instance_mock, class_mock, method_mock, property_mock, var_mock + instance_mock, class_mock, method_mock, property_mock, var_mock ) @@ -90,12 +91,11 @@ def it_can_add_a_page_break(self, add_page_break_fixture): assert paragraph is paragraph_ def it_can_add_a_picture(self, add_picture_fixture): - (document, image_path, width, height, inline_shapes_, expected_width, - expected_height, picture_) = add_picture_fixture - picture = document.add_picture(image_path, width, height) - inline_shapes_.add_picture.assert_called_once_with(image_path, ANY) - assert picture.width == expected_width - assert picture.height == expected_height + document, image_path_, width, height, run_, picture_ = ( + add_picture_fixture + ) + picture = document.add_picture(image_path_, width, height) + run_.add_picture.assert_called_once_with(image_path_, width, height) assert picture is picture_ def it_can_add_a_section(self, add_section_fixture): @@ -195,23 +195,14 @@ def add_page_break_fixture( self, document, document_part_, paragraph_, run_): return document, document_part_, paragraph_, run_ - @pytest.fixture(params=[ - (None, None, 200, 100), - (1000, 500, 1000, 500), - (2000, None, 2000, 1000), - (None, 2000, 4000, 2000), - ]) - def add_picture_fixture( - self, request, Document_inline_shapes_, inline_shapes_): - width, height, expected_width, expected_height = request.param + @pytest.fixture + def add_picture_fixture(self, request, run_, picture_): document = Document() image_path_ = instance_mock(request, str, name='image_path_') - picture_ = inline_shapes_.add_picture.return_value - picture_.width, picture_.height = 200, 100 - return ( - document, image_path_, width, height, inline_shapes_, - expected_width, expected_height, picture_ - ) + width, height = 100, 200 + class_mock(request, 'docx.text.Run', return_value=run_) + run_.add_picture.return_value = picture_ + return (document, image_path_, width, height, run_, picture_) @pytest.fixture def add_section_fixture(self, document, start_type_, section_): @@ -330,12 +321,6 @@ def open_(self, request, document_part_, package_): return_value=(document_part_, package_) ) - @pytest.fixture - def paragraph_(self, request, run_): - paragraph_ = instance_mock(request, Paragraph) - paragraph_.add_run.return_value = run_ - return paragraph_ - @pytest.fixture def Package_(self, request, package_): Package_ = class_mock(request, 'docx.api.Package') @@ -348,10 +333,20 @@ def package_(self, request, document_part_): package_.main_document = document_part_ return package_ + @pytest.fixture + def paragraph_(self, request, run_): + paragraph_ = instance_mock(request, Paragraph) + paragraph_.add_run.return_value = run_ + return paragraph_ + @pytest.fixture def paragraphs_(self, request): return instance_mock(request, list) + @pytest.fixture + def picture_(self, request): + return instance_mock(request, InlineShape) + @pytest.fixture def run_(self, request): return instance_mock(request, Run) From ba04f408af45ab8f7910db9de4740b5914efc12b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 11 Jul 2014 21:30:55 -0700 Subject: [PATCH 170/809] release: prepare v0.7.1 release --- HISTORY.rst | 6 ++++++ docx/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index fa4a711f3..4bb2190d6 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,12 @@ Release History --------------- +0.7.1 (2014-07-11) +++++++++++++++++++ + +- Add feature #14: Run.add_picture() + + 0.7.0 (2014-06-27) ++++++++++++++++++ diff --git a/docx/__init__.py b/docx/__init__.py index 0741920a9..fadaa1d8a 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.7.0' +__version__ = '0.7.1' # register custom Part classes with opc package reader From fffa94f45cf22bf250c7c048f7097358ed628589 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 13 Jul 2014 01:32:54 -0700 Subject: [PATCH 171/809] txt: use for line break instead of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Word does not interpret element in run as a line break, contrary to the behavior required by the ISO 29500 spec. Use element to implement line break when a line feed (‘\n’) or carriage return (‘\r’) character is encountered in text assigned to a run. --- docx/oxml/text.py | 2 +- tests/test_table.py | 2 +- tests/test_text.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docx/oxml/text.py b/docx/oxml/text.py index ee81ee02e..9fdd1d64b 100644 --- a/docx/oxml/text.py +++ b/docx/oxml/text.py @@ -420,7 +420,7 @@ def add_char(self, char): self._r.add_tab() elif char in '\r\n': self.flush() - self._r.add_cr() + self._r.add_br() else: self._bfr.append(char) diff --git a/tests/test_table.py b/tests/test_table.py index 4bfe4416d..8ac33c70e 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -136,7 +136,7 @@ def it_can_replace_its_content_with_a_string_of_text( ('w:tc/w:p', 'foobar', 'w:tc/w:p/w:r/w:t"foobar"'), ('w:tc/w:p', 'fo\tob\rar\n', - 'w:tc/w:p/w:r/(w:t"fo",w:tab,w:t"ob",w:cr,w:t"ar",w:cr)'), + 'w:tc/w:p/w:r/(w:t"fo",w:tab,w:t"ob",w:br,w:t"ar",w:br)'), ('w:tc/(w:tcPr, w:p, w:tbl, w:p)', 'foobar', 'w:tc/(w:tcPr, w:p/w:r/w:t"foobar")'), ]) diff --git a/tests/test_text.py b/tests/test_text.py index a39a3ed5f..f918c6d2f 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -506,8 +506,8 @@ def text_get_fixture(self, request): @pytest.fixture(params=[ ('abc def', 'w:r/w:t"abc def"'), ('abc\tdef', 'w:r/(w:t"abc", w:tab, w:t"def")'), - ('abc\ndef', 'w:r/(w:t"abc", w:cr, w:t"def")'), - ('abc\rdef', 'w:r/(w:t"abc", w:cr, w:t"def")'), + ('abc\ndef', 'w:r/(w:t"abc", w:br, w:t"def")'), + ('abc\rdef', 'w:r/(w:t"abc", w:br, w:t"def")'), ]) def text_set_fixture(self, request): new_text, expected_cxml = request.param From 457159fdcdf451fac7aac442bee9932aa24de469 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 13 Jul 2014 01:43:30 -0700 Subject: [PATCH 172/809] release: prepare v0.7.2 release --- HISTORY.rst | 6 ++++++ docx/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 4bb2190d6..022c03fec 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,12 @@ Release History --------------- +0.7.2 (2014-07-13) +++++++++++++++++++ + +- Fix: Word does not interpret as line feed + + 0.7.1 (2014-07-11) ++++++++++++++++++ diff --git a/docx/__init__.py b/docx/__init__.py index fadaa1d8a..df16b431d 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.7.1' +__version__ = '0.7.2' # register custom Part classes with opc package reader From 0984e9a62d793c85210e2da2c7d3805e1a1ea877 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 13 Jul 2014 13:21:20 -0700 Subject: [PATCH 173/809] docs: elaborate Table feature analysis --- docs/dev/analysis/features/table-cell.rst | 165 +++++++++++++++ docs/dev/analysis/features/table-props.rst | 217 +++++++++++++++++++ docs/dev/analysis/features/table.rst | 231 +++++++-------------- docs/dev/analysis/index.rst | 4 +- 4 files changed, 456 insertions(+), 161 deletions(-) create mode 100644 docs/dev/analysis/features/table-cell.rst create mode 100644 docs/dev/analysis/features/table-props.rst diff --git a/docs/dev/analysis/features/table-cell.rst b/docs/dev/analysis/features/table-cell.rst new file mode 100644 index 000000000..40be36b32 --- /dev/null +++ b/docs/dev/analysis/features/table-cell.rst @@ -0,0 +1,165 @@ + +Table Cell +========== + +All content in a table is contained in a cell. A cell also has several +properties affecting its size, appearance, and how the content it contains is +formatted. + + +MS API - Partial Summary +------------------------ + +* Merge() +* Split() +* Borders +* BottomPadding (and Left, Right, Top) +* Column +* ColumnIndex +* FitText +* Height +* HeightRule (one of WdRowHeightRule_ enumeration) +* Preferred Width +* Row +* RowIndex +* Shading +* Tables +* VerticalAlignment +* Width +* WordWrap + + +Specimen XML +------------ + +.. highlight:: xml + +:: + + + + + + + + + + + Amy earned her BA in American Studies + + + + + +Schema Definitions +------------------ + +.. highlight:: xml + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +.. _`WdRowHeightRule`: + http://msdn.microsoft.com/en-us/library/office/ff193620(v=office.15).aspx diff --git a/docs/dev/analysis/features/table-props.rst b/docs/dev/analysis/features/table-props.rst new file mode 100644 index 000000000..b2f8fbeba --- /dev/null +++ b/docs/dev/analysis/features/table-props.rst @@ -0,0 +1,217 @@ + +Table Properties +================ + + +Autofit +------- + +Word has two algorithms for laying out a table, *fixed-width* or *autofit*. +The default is autofit. Word will adjust column widths in an autofit table +based on cell contents. A fixed-width table retains its column widths +regardless of the contents. Either algorithm will adjust column widths +proportionately when total table width exceeds page width. + +The read/write :attr:`Table.allow_autofit` property specifies which algorithm +is used:: + + >>> table = document.add_table(rows=2, cols=2) + >>> table.allow_autofit + True + >>> table.allow_autofit = False + >>> table.allow_autofit + False + + +Specimen XML +------------ + +.. highlight:: xml + +The following XML is generated by Word when inserting a 2x2 table:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Layout behavior +--------------- + +Auto-layout causes actual column widths to be both unpredictable and +unstable. Changes to the content can make the table layout shift. + + +Semantics of CT_TblWidth element +-------------------------------- + +e.g. ``tcW``:: + + + + + + + + + + + + + ST_MeasurementOrPercent + | + +-- ST_DecimalNumberOrPercent + | | + | +-- ST_UnqualifiedPercentage + | | | + | | +-- XsdInteger e.g. '1440' + | | + | +-- ST_Percentage e.g. '-07.43%' + | + +-- ST_UniversalMeasure e.g. '-04.34mm' + + +Schema Definitions +------------------ + +.. highlight:: xml + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/dev/analysis/features/table.rst b/docs/dev/analysis/features/table.rst index 7812f5382..c934ddbb9 100644 --- a/docs/dev/analysis/features/table.rst +++ b/docs/dev/analysis/features/table.rst @@ -2,15 +2,11 @@ Table ===== -... column width is stored in twips (20ths of a point) ... +A table is composed of rows of cells. An implicit sequence of *grid columns* +align cells across rows. If there are no merged cells, the grid columns +correspond directly to the visual columns. -MS API ------- - -:: - - table = Document.Tables.Add(Range, NumRows, NumCols) - cell = table.Cell(row, col) +All table content is contained in its cells. Specimen XML @@ -62,40 +58,6 @@ The following XML is generated by Word when inserting a 2x2 table:: -Minimal XML ------------ - -.. highlight:: xml - -The following is the minimal XML implied by inserting a 2x2 table:: - - - - - - - - - - - - - - - - - - - - - - - - - - - - Schema Definitions ------------------ @@ -103,12 +65,17 @@ Schema Definitions :: - + - - - - + + + + + + + + + @@ -134,54 +101,6 @@ Schema Definitions - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -193,29 +112,6 @@ Schema Definitions - - - - - - - - - - - - - - - - - - - - - - - @@ -228,6 +124,8 @@ Schema Definitions + + @@ -237,57 +135,70 @@ Schema Definitions - - - - - - - - - + - - + + + + + + + + + + + + + + + + + + + + + + + + + - + - + + + + + + + + + + + + + + + + - - - - - - - - - + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + Resources diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index f161da127..7e4d7589e 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,9 @@ Feature Analysis .. toctree:: :maxdepth: 1 + features/table + features/table-props + features/table-cell features/par-alignment features/run-content features/numbering @@ -17,7 +20,6 @@ Feature Analysis features/char-style features/breaks features/sections - features/table features/shapes features/shapes-inline features/shapes-inline-size From 0528a7825d2465357e2e142c01ca719b3fdd19fe Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 13 Jul 2014 14:22:36 -0700 Subject: [PATCH 174/809] acpt: add scenarios for Table.autofit get and set --- docx/table.py | 9 +++++++ features/steps/table.py | 25 ++++++++++++++++++ features/steps/test_files/tbl-props.docx | Bin 0 -> 13978 bytes features/tbl-props.feature | 32 +++++++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 features/steps/test_files/tbl-props.docx create mode 100644 features/tbl-props.feature diff --git a/docx/table.py b/docx/table.py index eb7b62992..120f2c169 100644 --- a/docx/table.py +++ b/docx/table.py @@ -38,6 +38,15 @@ def add_row(self): tr.add_tc() return _Row(tr, self) + @property + def autofit(self): + """ + |True| if column widths can be automatically adjusted to improve the + fit of cell contents. |False| if table layout is fixed. Column widths + are adjusted in either case if total column width exceeds page width. + Read/write boolean. + """ + def cell(self, row_idx, col_idx): """ Return |_Cell| instance correponding to table cell at *row_idx*, diff --git a/features/steps/table.py b/features/steps/table.py index 3a8f31f98..363a1b8c7 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -74,6 +74,17 @@ def given_a_table_having_an_applied_style(context): context.table_ = document.tables[0] +@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')) + context.table_ = document.tables[tbl_idx] + + @given('a table having two columns') def given_a_table_having_two_columns(context): docx_path = test_docx('blk-containing-table') @@ -130,6 +141,13 @@ def when_I_set_the_column_width_to_width_emu(context, width_emu): context.column.width = new_value +@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] + table = context.table_ + table.autofit = new_value + + # then ===================================================== @then('I can access a cell using its row and column indices') @@ -284,6 +302,13 @@ def then_new_row_has_2_cells(context): assert len(context.row.cells) == 2 +@then('the reported autofit setting is {autofit}') +def then_the_reported_autofit_setting_is_autofit(context, 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}') def then_the_reported_column_width_is_width_emu(context, width_emu): expected_value = None if width_emu == 'None' else int(width_emu) diff --git a/features/steps/test_files/tbl-props.docx b/features/steps/test_files/tbl-props.docx new file mode 100644 index 0000000000000000000000000000000000000000..3014d724775ca06b3b95460d74a22568d8bde4e1 GIT binary patch literal 13978 zcmbuG1ymf#+wKQ4V-CaX)cMC2_COE;}U31y}%kJjN`Odiv z^qi@~^L9^Fb^q#>QkH{)#smNW@BkR6V{tmn5kyZ20ALpe0Kj-{)e&>BcQvzjHF)pk zXy&5F>}hA)lq{#z{|+(KK6FAzGXI)}&?=Sjk1*3@7ZKxZI@2bQnx(oD#11Avt)bo*xIX zsi=??bqF9%5!W}_aC{I7z68>2XA!aWoc zAuFY-?+c>lk`qTo^Ua&E`Hc#7Tlw%W zwhcQm3SGa~5m%emUYU1|eLx8r!6~o`o35GmvfqB6#4Nb#{HB5lk;{F$NMMfwWe|`*=Cn7&Xs9 z6Ka9opKo(JL{=4Fk0p39`y!*EV@^N1UGNO_$q;*u-B__iG(06&RJry+v>D zzdMMIJ|Cqj=M)oO2%hWd=k4ns!YTh_piob$+@4MXaLM#A}kW;4Elk??q_l4K0FUS4wgJ`sE>@*vV53;3itd z5M4Sh(g(P!%!7je}xL_LC*Q56nM%$+u-*JN$Jz zBSQfIz}H?fbudwOc5rlIF>-YLV=m{Yjw^1l0z0QQwRD|98_3!rrEmh5KYG{sg495?8Zfp8^xtf#( zjKQzg#y7rD3YXSOV0D(#)`~bCCbp^ z`WqJ^g~ulrF%J~4;_p&t+h}Ks;;e&q3g117h?B9EjZWhU9w6THLl>3w5_0y;vOjF` zQ}T#fhY6t+e_{0&P*>a@LM6>B8cyJeCTI`41~5s)@A%xhTB zVMV}w29&~dqV&i3)+SwTu~I{4`N#Jtg1z!WbWi+bZwsz9sX8SNIqvx4rD6ui;1LW8 z`TA)BG5{$py3aN53Vv<f4>OU+0ov%852I}*iLZ;9Hg-THN%_EP@za){*NAC5sT~9rGF_+A zj@`H-Q6dJxsRC3L6B1$fDsp+dN%;;F4)QOtd+|OHBF0-v?}cqmN97~Av=pd+$`0w( z?$fiOxj9Kf>#D1y@5yY`;17@%N~^89smP62c#k~1@Wlyz$`~^pYMJy3yp40E8a-+ zbI^R|!-cO>)(=;0KobCh`3t1*%{j#S=lmMJDOP>gAlaT zG_i$PPDAn;(I8+Np?M77V`BD&P$SmkVMdeFYe$m31Hw}q%}N$F7aFESs#kJE7HZg< z0kN8xB|yi~*qBX0NYKkz3c9Sai99?A@J+6Z8SdTF_uZYor#ZsAPq)i1Qyr(dIHa`+ z3Nsd&2Kgk0xUdj=P^KKmp80V~;_x){EUaH}@Bo%D2{JP0m~er?;&t(xr^l=wtG;mB zg37(v^1Z0?tS@7jW&GNl6^6T$oksHH-dGapV?;^>@aiz`SI%fCvYh1psEI^|E6vfg6QSksxTSU zo#UT6^e{2=*calI@KqGpEaq9G+=~DniQc{3c;@r|<*A;?TTB7_c@pG`Bj zUH&!7!~$+_1fAH}7310@;(pe*rx&%Gl1{$`Tg7&e*od8`@uM06-PQGvN3z^=bTNKk zRl7p+GHV-tL?h~nKJpK6kXpf?bfuxWcmZ%veFUSFKyo?GGcsZ;&o5(Q_(JBeQ3;o`)6)loYkvtAuAS{mct(Hr?v|@Io!rIxh{gK?Z{phtXv|FzTk`>b87zWQ_+)BTr!@tx#s5+>fs@3M(2wqF#eDu!ohVnZwLX2e6$5DZNd*9A0 zzCym2I{RnrnaAT0HO`a!WfT{vmbx zzHNklJ?jkpiSvcXA5!=Y>K)z_qkg33y2Rb6{q4bzA0taXwc{-uIyEI_+LxrO%S*n2 z_rS}F9pp=+9TB~kiN~yt47hZDvhl!{+nPt;6_@n^0oUVd>uHyLuYxaQVej=|$@M}o zjQ05tdrUZulv>d~oK`QMFE%ZYvR-gyT64k>Mu!q8usU;OmJa2dCi5bI6dnxF%F^ZKskEjoA~}z^2Gs= z9YJG8dZVx@1tp*NY2~^P7k&b@d~tTq)EH{jSa<@@?x(gqgw_CDp_p)y$w^@Bskh96 z%)Y7Nq5DQodSOxGk3Ol#o3k*~nk3-^D2nO85H^^Qa1-ozb-j*A(bWLh$dpPNS8k2y zAdL4EnNGE<)Zxl=aJ0CwYVg^zRLa)#&ZcnvL5m*oOPCbn83~Pja@E7t%dO*{;U4E8 zn<&R7+1YiO{`LbQ#7H{H!S-=Ke2luV}|kERW+_)Ki&yr)w@c`#QRpc^1-qq3>_V$ ztmZQd{I63yW7`tfxtWaG*a#Ocl_iwRQ5o`TUNM$gilQ%LJF4GtQ@gI*{Fo#myIQ=H zu3n}Z$zGZmcml%DP^sBR5_}5-S5okTbgPV6v zKx{lMb;PE!m_-#Y3=dy&-@4);lAEt#;JBGmw==G&pw0qNGy!0$(It9X^3MXook(ZK>K67j5xI{F`VJ}%uZ9IH-q#DG-QVxmev1XI|6 zOLgR)wiU6I_TNbJ%KK!uJ%TCd^!if-n>yCaJ;bdgMEk?N&s)kCw#T`THqUS0Fm$TT zIWx@*rsTwDl6G2V^LlRL?TqJUm7kXf_ZP;s==!;jZ<*d%*mEI}Gt7c+gEc!^=0lrp z2^}{E3y3BesFUews%Daoi5fDg*8C%`7kyz?^4Tn3M)2R_lL5G+{RGcKilA8SnU{FaLkPW)lOb4(lsMF%IvB_5B^ z+Qdrz{hZuV#W=NA`1mRNdopY7$XfMoF9&bxb&zJi280FwXybQ+8tJ`PRjT z<+}^B)ltd^MK~Ve`}8jVtsst%LZGzcNa4|g`<7&Z+7GjhqWA4q1!^GG#iB^~hD36X zlsI;EwhH-8=GsMOhOMb~PwUjDvN5|LHz{gl`1i#QPaUdbbgSpqZ}~fOYI1p9=0C`B zt5hZOi1%@PU@xsRilKhgZ+LH_rj{3`Z=zWg3sTb-)#R|ImsW1uCNfW8t>CE?%jJwc z4^2sv2}&G~Otn_6RwgjBW^T}z2Bq%Bw9r2b;GfOo&2$_%yM4Iord~mCo0$Htqq6WR zB>%;Fag{jep-L7jdNtE-X`~R=2bl8pMtzAH-MvSn@SbD1xxn0q1^s%ZPD#g4dCl+B zJBajQflp-XBkYY(c;wvTPc%}|T;-bkMGnRrrgL95I@QUSAWF|kYwce}f7BxjqF6#i(&P(}7d!|a1)lB?& zU0_^vrdeFCdSo{D^2$2s^)hkIqd>0T;Xw%s+Is3H!Z9VL;ZNY5)LFmH?v~`d`tce< z!FUIyY1EE~`k8uxmF=-lVrh(ge6=3L)lANBGd7pCh4@C~>d5If+TRl=-VU=Bdgg_q zb#|-R`QsHM?0Mw2T)lv=Dh0NkOUtf?ih=Te(a_c`S1!pv>}gWElvgPWn%&rIYE;a& zi$^E?CZv{h_2lRggs6I1dAWG~YO_&ksph2QHZZTo%0Q|VJD%IX4AOz|7OSgFc#~3! zfQ`C(+s8%|bsB+4k1A2DqN?K3xpZE4B)>_fqpOlMl0`uWRn3<>+Wg>2|3+o_&P2O3 z$dleI48dR9iK7}M7Y8MdmFDRc@>*tIUHEgk8mV(%LFg3QWGs@-E8goh zv56d)D#ki1dVtatH8CtH9s>!s@WWj%v&DM!^5`?>PxPHc0r?;GgaY7D#>q^VLC|cy;RuU)>Q82WL~3e?>rE zJ$yI2*MQX@ZsNb9AxV>p1FXQ{Q!m7EoV@TPNSFfNE`Vw+aB#s5EF*3#b#sx_GJ=U% zu}DwooDDjju&~Oh5o%BE{BRk=6;$HX507g$KKwpN5bM_BN3>^hX>tu0={z8}=7QEI zP{Vls-0iCmx@a^`qvVkT?`>tM;=}xnVZXS}4;kb+_0F^d35{MHnffjJY>~_G_%5w_ zW*E1_i|j<>y>;PpjYG-48ueNtT!FcMLDJBc`*=D%7}~w52r=w!8an2kV$ZP2O^7dF zvJKm^lO%LLhAcOpNnO||I5O^T^?v3zo*#4I$mx}~5Cjrxg%yuRSUL=3nmipS}6_emvG( zQ(9sL`tR0;Kg`ictIF<^BNl$HlgSFEC5THpgh)XUV>6)c+op;wguOzIBUxF+hagQtt5c|MKHiyiTkvKkZ{gr-S)$t-{QzCAlc z4-Ye?*53pLuM1O!tmGX52K{VDq@k2Jpe)fsq$2kVk~oN@Elh7^AQSWWd9PnLR@^I2 zN9*zN4!uGjCN1)LMm6|wyZ3U~P;J%FmIEDc{bzKrd;+Z6a6B1~mpgJg-khr<-gldi zT-JuEN&3uQ%0`4d0`iIg=peJP1H(FoW=ienZ+OlmiDqMVR9+&}DZvm%ed?kO8ZAEx zKX6otxD(I6J)*x13)6e35ofrwg19XpNtK~v$T*d}>Jqdce8-9RsJsWR&X}KU@y_9F zZap_zT$M?%V8RzfK1MR&)H)-*Bd9)zJu0dg_%eZ>+y^M(y+d~)o6UxxP+n#uTIdjP zCiHwaDbdP3-aq4_@yX^;AMD=6@zq8$Tm7@R?VJy1x<_KDpa;hZf^oh~C-KvP8*GZo zry=)cO)_k*Y@SZ$4GQ`ce_;FxK~lk(bq-I^HeJipwIo{q&@P=oG{K>i)xF&{lEcGe_K zlys5JU9>HT!JGgPpZlD#Fu)X{Jk_{e$1>K0hpiE!XEk%BR2Cetlk=vGC8b>8B(Qd)6C|qArfJ-~>{yNSshaWiuK-@E$J9HTQp&>V zEttkzJg>Y2o*Xo}W0VH5$T4PzS%wUs?^AC3BGK3p$&?zxqlvT-)Iy@5Erd-)vASPC^!rHZrN4w~yuT*NW!YHPY$&V>7A?1B8vD z7C&VSvo_iOSyF9$^12l{@%#g_Rrwec8ak5 z(rfU_BQI#CkPe%Qp~6NBb+Hk)-IaD3YKGGcW1Hkd@sX$7%@olh1tyVpzMQ=Ysm-T& z9BmCuPWR0Mx*1iVcz4{>)!hKays%&zcxG*2FMX~rB_vpyU z{3W;M9XGgYvBj;%jni3a$dMd_)O@4)&r0v0yID~Sy}5JHOfsr0HbYY}Mu(gk66W?p zwH&1^bnFyKVWb-hbcA4<8cnicYS|i1d6Bsjc#-Coq_w3ImxLX(vG$kalutLq&M%Fy zA_!cb;vQ#Vr^K>9vGnL0_)BT#3F-U2)MtUA?09NU?6oE33h^uF>`i64wXtpC2D0-S zMo|ORsn3btEe}oOw^|3Dp8}|3zK8&%blIa;>tVciZ=1`0q8nI_bf2N`33~OZXB}{Q zuCUaKA%5EMr>(__FPN9wr&5*`teLztP_2yJ_P#dhdT8rz)vE1&z8|2w4$cTTbj?NU zTPL2^bPx-`_bYkMvH7`wXt&hx5sdQB8TG}dXuaWkNy!NnX`G}3^uWT^Z9{DwvdwI3wW1_+!Gl1#Y|+#o$c>X|VwmL;KW*&k}vCB=aD?Wdl6rYSV!r&SKNqpR4(vSrevreuTgBr5d zOUWDIj=@}xX@1IcydQ$ndX4zI!Mm8rseHH}VKyecj->i7?*}xb71&{;fJF!n%t3&xgN|eY-icf2V++Hh$VK2ZqHAo1 ze(=TU9ZvgR{Y|dFNPsFO8XvKksEcKsf7#H zLjGjY0WpxlF27)OcDKt7VFpX)p7MyRyg%Wp+f+7vdsEmu{NNV%L#5|xkBGBl^})nujR|Qcz#3FZyqzCWd3fwKYC6{<65?05{IPUm4#V$*PIXvX;a&X?q0#3- zV?o9nB`y`EV(V^MsKGOxgST+ub#h{(f!NLPHZsHd*`Ig!c9xvWCM|H}^pfs<`(5%~ z%DfYOZbhegQssoadG=8*TR%1yLSx`%x{~mo*?59oQ}wFujX7C+01D?*0@74>XXuB^ ziR2yCXq}Y-#c`t`*|@0t*6X{o>z~EjmSVwDkaY8{*Tb%d;VeH2J-Ptl&=q4oJJBiS*8^ck{ktvMtlHry+n|yMndMB4@!*Tes zoW?#;>IKWE4|dmR84Ai4_z~|fFCh&sMZMQArZm%D;tmrzV4>7yTyc$)jTKc)`Q~hn zYG+a(j;;yrT$1q*`EVF~b>+@nO#U@6M(^;;^V6u4?XFj%ejOeNT5^O+CZk;w1kz5% z*-_fR)TE1c*H?@v%@&@wtlo^*8J=vLS#EN`o!!xOQT6r8Nf|nQZrzZSt5hhs3-VdE ztDU`e&^gS2HfiyA@9kF6(v2LRzRQxq>zR$Y$%v`I!{l#cPApPUfR^NQ)Gn zDiHzFJ=DXYQNhh(P2zO0gW>K-x{C0^FfoW;iOs?mT3uyePMMsnad z9Cv6o3B?0ZrMuRSJ8o6VF(s&C`bm0=zfLwyS>2vGs^SC8iUhp3aWdwmtVV5EZ#ybQ zp5>=1E8Kx|phHV`59Tes*NDzlO^_oAaLgCaQ=ae5(f6b6tqxaev;sm_c-&3Dk=<@5 zYX5p_O>{UNSV-u$&BgZ^&76qh0F)C$xdRPgH(MM_e`guMr&xsKpEM}zNZP5ZXQ`Y@m{6{}< znfF-r1P6js|M+N!<~~K&L{c38-o73G$2%^)RGWN9@Gkzl?2vX?pwZALktK)ym!aIY zDW2u)5j~unt`xGH7FX$-oQdfW&hJ@U#KQ(SE&Z$o8Bk(3Z0@x-vf3tRXp~K`C?YyG zGeGfX*qVaHei`X{9jNg99xh9GQQeQBuWL1H!AmkX)jue+)fi zdp*!X0PqW;eJ4CLQ1BC>-zUXWy}DV+004>#B!E*K0pOcrA_D+~P(c7NKS2WmTVGd< z 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 CcV%_} literal 0 HcmV?d00001 diff --git a/features/tbl-props.feature b/features/tbl-props.feature new file mode 100644 index 000000000..faaa0c4fe --- /dev/null +++ b/features/tbl-props.feature @@ -0,0 +1,32 @@ +Feature: Get and set table properties + In order to format a table to my requirements + As an python-docx developer + I need a way to get and set a table's properties + + + @wip + Scenario Outline: Get autofit layout setting + Given a table having an autofit layout of + Then the reported autofit setting is + + Examples: table autofit settings + | autofit-setting | reported-autofit | + | no explicit setting | autofit | + | autofit | autofit | + | fixed | fixed | + + + @wip + Scenario Outline: Set autofit layout setting + Given a table having an autofit layout of + When I set the table autofit to + Then the reported autofit setting is + + Examples: table column width values + | autofit-setting | new-setting | reported-autofit | + | no explicit setting | autofit | autofit | + | no explicit setting | fixed | fixed | + | fixed | autofit | autofit | + | autofit | autofit | autofit | + | fixed | fixed | fixed | + | autofit | fixed | fixed | From d571aaea4ef2cb4eda08a968ba52f5235787f4bd Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 13 Jul 2014 15:01:15 -0700 Subject: [PATCH 175/809] tbl: add Table.autofit getter * Fixed sequencing of w:tblPr/w:tblStyle child element, was defaulting to unconditional append. --- docx/oxml/__init__.py | 18 ++++++++++-------- docx/oxml/simpletypes.py | 12 ++++++++++++ docx/oxml/table.py | 32 ++++++++++++++++++++++++++++++-- docx/table.py | 1 + features/tbl-props.feature | 1 - tests/test_table.py | 15 +++++++++++++++ 6 files changed, 68 insertions(+), 11 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index 39e042aa5..c8f9270b9 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -114,15 +114,17 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): register_element_cls('w:type', CT_SectType) from docx.oxml.table import ( - CT_Row, CT_Tbl, CT_TblGrid, CT_TblGridCol, CT_TblPr, CT_Tc + CT_Row, CT_Tbl, CT_TblGrid, CT_TblGridCol, CT_TblLayoutType, CT_TblPr, + CT_Tc ) -register_element_cls('w:gridCol', CT_TblGridCol) -register_element_cls('w:tbl', CT_Tbl) -register_element_cls('w:tblGrid', CT_TblGrid) -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:tr', CT_Row) +register_element_cls('w:gridCol', CT_TblGridCol) +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:tr', CT_Row) from docx.oxml.text import ( CT_Br, CT_Jc, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text, CT_Underline diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 584844a48..aee9703bc 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -264,6 +264,18 @@ class ST_String(XsdString): pass +class ST_TblLayoutType(XsdString): + + @classmethod + def validate(cls, value): + cls.validate_string(value) + valid_values = ('fixed', 'autofit') + if value not in valid_values: + raise ValueError( + "must be one of %s, got '%s'" % (valid_values, value) + ) + + class ST_TwipsMeasure(XsdUnsignedLong): @classmethod diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 38bde46ea..a29a0eb07 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -8,7 +8,7 @@ from . import parse_xml from .ns import nsdecls -from .simpletypes import ST_TwipsMeasure +from .simpletypes import ST_TblLayoutType, ST_TwipsMeasure from .xmlchemy import ( BaseOxmlElement, OneAndOnlyOne, OneOrMore, OptionalAttribute, ZeroOrOne, ZeroOrMore @@ -70,12 +70,40 @@ class CT_TblGridCol(BaseOxmlElement): w = OptionalAttribute('w:w', ST_TwipsMeasure) +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) + + class CT_TblPr(BaseOxmlElement): """ ```` element, child of ````, holds child elements that define table properties such as style and borders. """ - tblStyle = ZeroOrOne('w:tblStyle') + tblStyle = ZeroOrOne('w:tblStyle', successors=( + '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' + )) + tblLayout = ZeroOrOne('w:tblLayout', successors=( + 'w:tblLayout', 'w:tblCellMar', 'w:tblLook', 'w:tblCaption', + 'w:tblDescription', 'w:tblPrChange' + )) + + @property + def autofit(self): + """ + Return |False| if there is a ```` child with ``w:type`` + attribute set to ``'fixed'``. Otherwise return |True|. + """ + tblLayout = self.tblLayout + if tblLayout is None: + return True + return False if tblLayout.type == 'fixed' else True @property def style(self): diff --git a/docx/table.py b/docx/table.py index 120f2c169..7f27cb76e 100644 --- a/docx/table.py +++ b/docx/table.py @@ -46,6 +46,7 @@ def autofit(self): are adjusted in either case if total column width exceeds page width. Read/write boolean. """ + return self._tblPr.autofit def cell(self, row_idx, col_idx): """ diff --git a/features/tbl-props.feature b/features/tbl-props.feature index faaa0c4fe..ce09e9185 100644 --- a/features/tbl-props.feature +++ b/features/tbl-props.feature @@ -4,7 +4,6 @@ Feature: Get and set table properties I need a way to get and set a table's properties - @wip Scenario Outline: Get autofit layout setting Given a table having an autofit layout of Then the reported autofit setting is diff --git a/tests/test_table.py b/tests/test_table.py index 8ac33c70e..e9899af30 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -60,6 +60,10 @@ def it_can_apply_a_table_style_by_name(self, table_style_set_fixture): table.style = style_name assert table._tbl.xml == expected_xml + def it_knows_whether_it_should_autofit(self, autofit_get_fixture): + table, expected_value = autofit_get_fixture + assert table.autofit is expected_value + # fixtures ------------------------------------------------------- @pytest.fixture @@ -76,6 +80,17 @@ def add_row_fixture(self): expected_xml = _tbl_bldr(rows=2, cols=2).xml() return table, expected_xml + @pytest.fixture(params=[ + ('w:tbl/w:tblPr', True), + ('w:tbl/w:tblPr/w:tblLayout', True), + ('w:tbl/w:tblPr/w:tblLayout{w:type=autofit}', True), + ('w:tbl/w:tblPr/w:tblLayout{w:type=fixed}', False), + ]) + def autofit_get_fixture(self, request): + tbl_cxml, expected_autofit = request.param + table = Table(element(tbl_cxml), None) + return table, expected_autofit + @pytest.fixture(params=[ ('w:tbl/w:tblPr', None), ('w:tbl/w:tblPr/w:tblStyle{w:val=foobar}', 'foobar'), From f19d906ef5197540b72288b02c8dc05819f6ff4d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 13 Jul 2014 15:34:23 -0700 Subject: [PATCH 176/809] tbl: add Table.autofit setter --- docx/oxml/table.py | 5 +++++ docx/table.py | 4 ++++ features/tbl-props.feature | 1 - tests/test_table.py | 23 +++++++++++++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index a29a0eb07..2f4f941e7 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -105,6 +105,11 @@ def autofit(self): return 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' + @property def style(self): """ diff --git a/docx/table.py b/docx/table.py index 7f27cb76e..a6a99d304 100644 --- a/docx/table.py +++ b/docx/table.py @@ -48,6 +48,10 @@ def autofit(self): """ return self._tblPr.autofit + @autofit.setter + def autofit(self, value): + self._tblPr.autofit = value + def cell(self, row_idx, col_idx): """ Return |_Cell| instance correponding to table cell at *row_idx*, diff --git a/features/tbl-props.feature b/features/tbl-props.feature index ce09e9185..613f2299c 100644 --- a/features/tbl-props.feature +++ b/features/tbl-props.feature @@ -15,7 +15,6 @@ Feature: Get and set table properties | fixed | fixed | - @wip Scenario Outline: Set autofit layout setting Given a table having an autofit layout of When I set the table autofit to diff --git a/tests/test_table.py b/tests/test_table.py index e9899af30..7df8fe9fc 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -64,6 +64,11 @@ def it_knows_whether_it_should_autofit(self, autofit_get_fixture): table, expected_value = autofit_get_fixture assert table.autofit is expected_value + def it_can_change_its_autofit_setting(self, autofit_set_fixture): + table, new_value, expected_xml = autofit_set_fixture + table.autofit = new_value + assert table._tbl.xml == expected_xml + # fixtures ------------------------------------------------------- @pytest.fixture @@ -91,6 +96,24 @@ def autofit_get_fixture(self, request): table = Table(element(tbl_cxml), None) return table, expected_autofit + @pytest.fixture(params=[ + ('w:tbl/w:tblPr', True, + 'w:tbl/w:tblPr/w:tblLayout{w:type=autofit}'), + ('w:tbl/w:tblPr', False, + 'w:tbl/w:tblPr/w:tblLayout{w:type=fixed}'), + ('w:tbl/w:tblPr', None, + 'w:tbl/w:tblPr/w:tblLayout{w:type=fixed}'), + ('w:tbl/w:tblPr/w:tblLayout{w:type=fixed}', True, + 'w:tbl/w:tblPr/w:tblLayout{w:type=autofit}'), + ('w:tbl/w:tblPr/w:tblLayout{w:type=autofit}', False, + 'w:tbl/w:tblPr/w:tblLayout{w:type=fixed}'), + ]) + def autofit_set_fixture(self, request): + tbl_cxml, new_value, expected_tbl_cxml = request.param + table = Table(element(tbl_cxml), None) + expected_xml = xml(expected_tbl_cxml) + return table, new_value, expected_xml + @pytest.fixture(params=[ ('w:tbl/w:tblPr', None), ('w:tbl/w:tblPr/w:tblStyle{w:val=foobar}', 'foobar'), From ef4d4ee357872c690db0d6bc4d8789dfc3b6cc7b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 13 Jul 2014 16:18:12 -0700 Subject: [PATCH 177/809] acpt: add scenarios for Cell.width --- docx/table.py | 6 +++++ features/steps/table.py | 25 +++++++++++++++++++++ features/steps/test_files/tbl-props.docx | Bin 13978 -> 13981 bytes features/tbl-cell-props.feature | 27 +++++++++++++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 features/tbl-cell-props.feature diff --git a/docx/table.py b/docx/table.py index a6a99d304..901ab617f 100644 --- a/docx/table.py +++ b/docx/table.py @@ -121,6 +121,12 @@ def text(self, text): r = p.add_r() r.text = text + @property + def width(self): + """ + The width of this cell in EMU, or |None| if no explicit width is set. + """ + class _Column(Parented): """ diff --git a/features/steps/table.py b/features/steps/table.py index 363a1b8c7..2d7aa37dc 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -9,6 +9,7 @@ from behave import given, then, when from docx import Document +from docx.shared import Inches from docx.table import ( _Cell, _Column, _ColumnCells, _Columns, _Row, _RowCells, _Rows ) @@ -56,6 +57,15 @@ def given_a_table(context): context.table_ = Document().add_table(rows=2, cols=2) +@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 = document.tables[table_idx] + cell = table.cell(0, 0) + context.cell = cell + + @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 = { @@ -135,6 +145,12 @@ def when_apply_style_to_table(context): table.style = 'LightShading-Accent1' +@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] + context.cell.width = new_value + + @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) @@ -317,6 +333,15 @@ def then_the_reported_column_width_is_width_emu(context, width_emu): ) +@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] + actual_width = context.cell.width + assert actual_width == expected_width, ( + 'expected %s, got %s' % (expected_width, actual_width) + ) + + @then('the table style matches the name I applied') def then_table_style_matches_name_applied(context): table = context.table_ diff --git a/features/steps/test_files/tbl-props.docx b/features/steps/test_files/tbl-props.docx index 3014d724775ca06b3b95460d74a22568d8bde4e1..fab223b3d035598b00d42b8a313bff7bbf7cb6b2 100644 GIT binary patch delta 1142 zcmbQ0J2#gnz?+#xgn@y9gJE^UMxN!2Osg9g)3=$nfa!@W6Tq}GTQ3htev^<7 zBj4(Vw=UsNIfWIO85lnBP394itdI7;Z6H$jJlv)BrLc--?nx!CMH>v4_^#5pEvJ6H zP-t~uuBD&%_t)ifsx?AnYZGQybe(@|J^#FA*`&{>kGDi>FACE7vE=3RBSfYpF#_LY3-wqVctx=tNK*d+_uIVG~Dc`(yxV&kfrVyjc-uJlC zr?PkBSr3mx2PL?sD)27qQgG5WuH=~^l(}m`&#T^pQr8Vud+G<@(KqbNmQy>%&h$?! zaq8w9Poo8N&2>#&=ySez=wM&OjoJm%B$e?;-fA@}njDrn=A$N>~Z|=6(q_bmzn9_|Isd_J1 zwXaPz37#5LpZTonaL3kf;T^tJ{A{1YE-py7|N5KJ&OpVFe_oZcqVcIMH@^K=I&2p-=o85 zS@g8Bz_(^mdQ+=)eqP)AZb^arxtj&QV!AFR?SI*`p!Y<{am^)Fx8Kh)_$InieQS!! z6<4L}vZgDW+^@Kuh!yR9WLtAOC~s-?{1?;JqaH^tH(a_&hOykDq-^W4du$cW!JG2C z_CK1^@@1k;^S3&;e^-r-KGg5||FCDvscj9K2KHjby;CGLqs;kLHyoY6J*n%%rqDlX zPn7vzG3;KbnjNHhuxi_4-Qo?>Ym2R9c0V@LmONWO^|P&n$n_we*AvZ4tU^*)nsa|C zrW7|mUC!)s{^Zft3sEY)H|K0VCM7H)Akn|s(eSa;i)j+6R^q!OHzsIG1a}%3YR23S z`*GJ~ai&qAUs_1=f;y{rS5~a>`S&wD{C<5}=-*XwGJn~SGu&oh@c<@Jj+!rdj~PVQ z%QmxtXciSgb`UMD{T<8~Ffsz=z{xJg_8_X&*c?RdGqwRye~fKGl(mU7h-xtLK+l`k zObkIXoTf62E|b+v6+s+NAZOa-G*bl-r^8gnu1uDJp*+7RMZdVDGAFfIuOc@mz?+dt zgc(*kymfIo`}EmuHU@@0E({D(z%l~{8W?|1eq^c%GKtqrhLLZwmYE8O6JRF8#BMZM Q!ANX!i!lLcq7558XSZlb_fLLp z(HVNYFK7AE->=Kh`7cimb(f!^Ht*HvyS3kcevX|L6JxvlOr+4t6%QuwtAF%W_RvNS z&3^U2@B8QX9j|rIJmP$WMSb$WSu6fseR}bz76^Y~YFUWbbS{7JGj8LlB?nwIHOiZR2(64`o!<5LLQu==rw2YC zO9;M~kYg+)`S3wci_b*1;A0aM#VgBrW=LKt@sqro-E5kbFl}<2US&X{jJb`E9|KEM z>Zw-=8@0EoME$)YStLIx^;E8ErQ`XjEsTi=L+hP3hi$*UsL^<(W0~9yt2bJo+uLJ* zt##4X@j*p4A?s-m3*Ojv6 zdCMJd67fOdhxD3PeQy@8JbdDe(3FD=OD%o~6)Rqx!>Al^$58g>Zkr;R3Wu|GjAT&?F=}#<>^NKQ(Gm)!dhc9FMRP|-ctW;F8^}v7qo^W=B>?sZIj{*P1oZ+M+rTl79S>FfV`3)SD# zeyA@~S#z415mGdW>&lU8p-)33T_%mW=ri!ok#b=)axZM9#sxRZv*ZFvRO218x z(4A>v*Cqt9y#6hF-H3Vr&98p#%JnImPd&Q1e${^V=EQ^QjbCq8yeM6fVra5$CqLu+ zAOAM==qzJCvoU-|@xrYh(c5QkzBXGxL@1;Gv*XRjP8p|rieqQoT@_)#>Ni!<=Tv~+ zZSMISmYMpho;qoJRbs{bJ}arIr$p9I$@}Y<8TR%0`==N4k#pPT0Pz4OP~mQ&UY4 tN5D*mk$bYPnTk9p$G?br8qC1Rz_1O7LB$3PG%&JEE-+JPGcyK>002PX<2e8T diff --git a/features/tbl-cell-props.feature b/features/tbl-cell-props.feature new file mode 100644 index 000000000..d731ceddf --- /dev/null +++ b/features/tbl-cell-props.feature @@ -0,0 +1,27 @@ +Feature: Get and set table cell properties + In order to format a table cell to my requirements + As an python-docx developer + I need a way to get and set the properties of a table cell + + + @wip + Scenario Outline: Get cell width + Given a table cell having a width of + Then the reported width of the cell is + + Examples: Table cell width settings + | width-setting | reported-width | + | no explicit setting | None | + | 1 inch | 1 inch | + + + @wip + Scenario Outline: Set cell width + Given a table cell having a width of + When I set the cell width to + Then the reported width of the cell is + + Examples: table column width values + | width-setting | new-setting | reported-width | + | no explicit setting | 1 inch | 1 inch | + | 2 inches | 1 inch | 1 inch | From 70e39d3974dffc68091832f4676088494bf64d09 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 13 Jul 2014 17:07:00 -0700 Subject: [PATCH 178/809] test: reorder fixtures in Describe_Cell --- tests/test_table.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_table.py b/tests/test_table.py index 7df8fe9fc..76a7a86e1 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -170,6 +170,10 @@ def it_can_replace_its_content_with_a_string_of_text( # fixtures ------------------------------------------------------- + @pytest.fixture + def paragraphs_fixture(self): + return _Cell(element('w:tc/(w:p, w:p)'), None) + @pytest.fixture(params=[ ('w:tc/w:p', 'foobar', 'w:tc/w:p/w:r/w:t"foobar"'), @@ -184,10 +188,6 @@ def text_set_fixture(self, request): expected_xml = xml(expected_cxml) return cell, new_text, expected_xml - @pytest.fixture - def paragraphs_fixture(self): - return _Cell(element('w:tc/(w:p, w:p)'), None) - class Describe_Column(object): From fa30e11bbdf2406017e89d805c622e706a8759d5 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 13 Jul 2014 16:41:42 -0700 Subject: [PATCH 179/809] tbl: add _Cell.width getter --- docx/oxml/__init__.py | 4 +- docx/oxml/simpletypes.py | 12 ++++++ docx/oxml/table.py | 75 +++++++++++++++++++++++++++++++-- docx/table.py | 1 + features/tbl-cell-props.feature | 1 - tests/test_table.py | 15 +++++++ tests/unitutil/cxml.py | 2 +- 7 files changed, 104 insertions(+), 6 deletions(-) diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index c8f9270b9..c5938c7c8 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -115,7 +115,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): from docx.oxml.table import ( CT_Row, CT_Tbl, CT_TblGrid, CT_TblGridCol, CT_TblLayoutType, CT_TblPr, - CT_Tc + CT_TblWidth, CT_Tc, CT_TcPr ) register_element_cls('w:gridCol', CT_TblGridCol) register_element_cls('w:tbl', CT_Tbl) @@ -124,6 +124,8 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): 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) from docx.oxml.text import ( diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index aee9703bc..07b51d533 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -276,6 +276,18 @@ def validate(cls, value): ) +class ST_TblWidth(XsdString): + + @classmethod + def validate(cls, value): + cls.validate_string(value) + valid_values = ('auto', 'dxa', 'nil', 'pct') + if value not in valid_values: + raise ValueError( + "must be one of %s, got '%s'" % (valid_values, value) + ) + + class ST_TwipsMeasure(XsdUnsignedLong): @classmethod diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 2f4f941e7..267f1c648 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -8,10 +8,13 @@ from . import parse_xml from .ns import nsdecls -from .simpletypes import ST_TblLayoutType, ST_TwipsMeasure +from ..shared import Emu, Twips +from .simpletypes import ( + ST_TblLayoutType, ST_TblWidth, ST_TwipsMeasure, XsdInt +) from .xmlchemy import ( - BaseOxmlElement, OneAndOnlyOne, OneOrMore, OptionalAttribute, ZeroOrOne, - ZeroOrMore + BaseOxmlElement, OneAndOnlyOne, OneOrMore, OptionalAttribute, + RequiredAttribute, ZeroOrOne, ZeroOrMore ) @@ -129,6 +132,33 @@ def style(self, value): self._add_tblStyle(val=value) +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) + + @property + def width(self): + """ + Return the EMU length value represented by the combined ``w:w`` and + ``w:type`` attributes. + """ + if self.type != 'dxa': + return None + return Twips(self.w) + + @width.setter + def width(self, value): + self.type = 'dxa' + self.w = Emu(value).twips + + class CT_Tc(BaseOxmlElement): """ ```` table cell element @@ -170,3 +200,42 @@ def new(cls): ' \n' '' % nsdecls('w') ) + + @property + def width(self): + """ + Return the EMU length value represented in the ``./w:tcPr/w:tcW`` + child element or |None| if not present. + """ + tcPr = self.tcPr + if tcPr is None: + return None + return tcPr.width + + +class CT_TcPr(BaseOxmlElement): + """ + ```` element, defining table cell properties + """ + tcW = ZeroOrOne('w:tcW', successors=( + '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' + )) + + @property + def width(self): + """ + Return the EMU length value represented in the ```` child + element or |None| if not present or its type is not 'dxa'. + """ + tcW = self.tcW + if tcW is None: + return None + return tcW.width + + @width.setter + def width(self, value): + tcW = self.get_or_add_tcW() + tcW.width = value diff --git a/docx/table.py b/docx/table.py index 901ab617f..1b164c339 100644 --- a/docx/table.py +++ b/docx/table.py @@ -126,6 +126,7 @@ def width(self): """ The width of this cell in EMU, or |None| if no explicit width is set. """ + return self._tc.width class _Column(Parented): diff --git a/features/tbl-cell-props.feature b/features/tbl-cell-props.feature index d731ceddf..5d33bddfd 100644 --- a/features/tbl-cell-props.feature +++ b/features/tbl-cell-props.feature @@ -4,7 +4,6 @@ Feature: Get and set table cell properties I need a way to get and set the properties of a table cell - @wip Scenario Outline: Get cell width Given a table cell having a width of Then the reported width of the cell is diff --git a/tests/test_table.py b/tests/test_table.py index 76a7a86e1..6636c36d5 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -168,6 +168,10 @@ def it_can_replace_its_content_with_a_string_of_text( cell.text = text assert cell._tc.xml == expected_xml + def it_knows_its_width_in_EMU(self, width_get_fixture): + cell, expected_width = width_get_fixture + assert cell.width == expected_width + # fixtures ------------------------------------------------------- @pytest.fixture @@ -188,6 +192,17 @@ def text_set_fixture(self, request): expected_xml = xml(expected_cxml) return cell, new_text, expected_xml + @pytest.fixture(params=[ + ('w:tc', None), + ('w:tc/w:tcPr', None), + ('w:tc/w:tcPr/w:tcW{w:w=25%,w:type=pct}', None), + ('w:tc/w:tcPr/w:tcW{w:w=1440,w:type=dxa}', 914400), + ]) + def width_get_fixture(self, request): + tc_cxml, expected_width = request.param + cell = _Cell(element(tc_cxml), None) + return cell, expected_width + class Describe_Column(object): diff --git a/tests/unitutil/cxml.py b/tests/unitutil/cxml.py index e39babf4d..c66bc0091 100644 --- a/tests/unitutil/cxml.py +++ b/tests/unitutil/cxml.py @@ -228,7 +228,7 @@ def grammar(): # np:attr_name=attr_val ---------------------- attr_name = Word(alphas + ':') - attr_val = Word(alphanums + '-.') + attr_val = Word(alphanums + '-.%') attr_def = Group(attr_name + equal + attr_val) attr_list = open_brace + delimitedList(attr_def) + close_brace From 6113e40453cf9b79f336801ef9a35c2218e073d2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 13 Jul 2014 16:58:36 -0700 Subject: [PATCH 180/809] tbl: add _Cell.width setter --- docx/oxml/table.py | 5 +++++ docx/table.py | 4 ++++ features/tbl-cell-props.feature | 1 - tests/test_table.py | 19 +++++++++++++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 267f1c648..9f4b20e2a 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -212,6 +212,11 @@ def width(self): return None return tcPr.width + @width.setter + def width(self, value): + tcPr = self.get_or_add_tcPr() + tcPr.width = value + class CT_TcPr(BaseOxmlElement): """ diff --git a/docx/table.py b/docx/table.py index 1b164c339..26e8dc5c9 100644 --- a/docx/table.py +++ b/docx/table.py @@ -128,6 +128,10 @@ def width(self): """ return self._tc.width + @width.setter + def width(self, value): + self._tc.width = value + class _Column(Parented): """ diff --git a/features/tbl-cell-props.feature b/features/tbl-cell-props.feature index 5d33bddfd..620a55092 100644 --- a/features/tbl-cell-props.feature +++ b/features/tbl-cell-props.feature @@ -14,7 +14,6 @@ Feature: Get and set table cell properties | 1 inch | 1 inch | - @wip Scenario Outline: Set cell width Given a table cell having a width of When I set the cell width to diff --git a/tests/test_table.py b/tests/test_table.py index 6636c36d5..736dbb1b2 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -8,6 +8,7 @@ import pytest +from docx.shared import Inches from docx.table import ( _Cell, _Column, _ColumnCells, _Columns, _Row, _RowCells, _Rows, Table ) @@ -172,6 +173,12 @@ def it_knows_its_width_in_EMU(self, width_get_fixture): cell, expected_width = width_get_fixture assert cell.width == expected_width + def it_can_change_its_width(self, width_set_fixture): + cell, value, expected_xml = width_set_fixture + cell.width = value + assert cell.width == value + assert cell._tc.xml == expected_xml + # fixtures ------------------------------------------------------- @pytest.fixture @@ -203,6 +210,18 @@ def width_get_fixture(self, request): cell = _Cell(element(tc_cxml), None) return cell, expected_width + @pytest.fixture(params=[ + ('w:tc', Inches(1), + 'w:tc/w:tcPr/w:tcW{w:w=1440,w:type=dxa}'), + ('w:tc/w:tcPr/w:tcW{w:w=25%,w:type=pct}', Inches(2), + 'w:tc/w:tcPr/w:tcW{w:w=2880,w:type=dxa}'), + ]) + def width_set_fixture(self, request): + tc_cxml, new_value, expected_cxml = request.param + cell = _Cell(element(tc_cxml), None) + expected_xml = xml(expected_cxml) + return cell, new_value, expected_xml + class Describe_Column(object): From e192ebca494b77c7fdcbea71dae950a65ea45679 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 14 Jul 2014 14:09:15 -0700 Subject: [PATCH 181/809] release: prepare v0.7.3 release --- HISTORY.rst | 7 +++++++ docx/__init__.py | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 022c03fec..fa825a9ae 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,13 @@ Release History --------------- +0.7.3 (2014-07-14) +++++++++++++++++++ + +- Add Table.autofit +- Add feature #46: _Cell.width + + 0.7.2 (2014-07-13) ++++++++++++++++++ diff --git a/docx/__init__.py b/docx/__init__.py index df16b431d..4a8378d6f 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.7.2' +__version__ = '0.7.3' # register custom Part classes with opc package reader From 235e2f1571bafb68771548b19db394305f870e86 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 17 Jul 2014 22:21:54 -0700 Subject: [PATCH 182/809] doc: refactor _Body.add_paragraph() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Migrate (text=‘’, style=None) interface for add_paragraph() down to actual implementation so it can be shared when extracted to BlockItemContainer. * Combine unit tests for add_paragraph() in test_api.py --- docx/api.py | 7 +----- docx/parts/document.py | 18 ++++++++++----- tests/parts/test_document.py | 2 +- tests/test_api.py | 43 +++++++++++++----------------------- 4 files changed, 30 insertions(+), 40 deletions(-) diff --git a/docx/api.py b/docx/api.py index 146786170..c1ac093b7 100644 --- a/docx/api.py +++ b/docx/api.py @@ -69,12 +69,7 @@ def add_paragraph(self, text='', style=None): return (``\\r``) characters, each of which is converted to a line break. """ - paragraph = self._document_part.add_paragraph() - if text: - paragraph.add_run(text) - if style is not None: - paragraph.style = style - return paragraph + return self._document_part.add_paragraph(text, style) def add_picture(self, image_path_or_stream, width=None, height=None): """ diff --git a/docx/parts/document.py b/docx/parts/document.py index 5253d714c..2eeb77fc4 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -24,11 +24,11 @@ class DocumentPart(XmlPart): """ Main document part of a WordprocessingML (WML) package, aka a .docx file. """ - def add_paragraph(self): + def add_paragraph(self, text='', style=None): """ Return a paragraph newly added to the end of body content. """ - return self.body.add_paragraph() + return self.body.add_paragraph(text, style) def add_section(self, start_type=WD_SECTION.NEW_PAGE): """ @@ -122,12 +122,20 @@ def __init__(self, body_elm, parent): super(_Body, self).__init__(parent) self._body = body_elm - def add_paragraph(self): + def add_paragraph(self, text='', style=None): """ - Return a paragraph newly added to the end of body content. + Return a paragraph newly added to the end of body content, having + *text* in a single run if present, and having paragraph style + *style*. If *style* is |None|, no paragraph style is applied, which + has the same effect as applying the 'Normal' style. """ p = self._body.add_p() - return Paragraph(p, self) + paragraph = Paragraph(p, self) + if text: + paragraph.add_run(text) + if style is not None: + paragraph.style = style + return paragraph def add_table(self, rows, cols): """ diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 55b7b8aab..6c0e48978 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -66,7 +66,7 @@ def it_provides_access_to_the_inline_shapes_in_the_document( def it_can_add_a_paragraph(self, add_paragraph_fixture): document_part, body_, p_ = add_paragraph_fixture p = document_part.add_paragraph() - body_.add_paragraph.assert_called_once_with() + body_.add_paragraph.assert_called_once_with('', None) assert p is p_ def it_can_add_a_section(self, add_section_fixture): diff --git a/tests/test_api.py b/tests/test_api.py index 189fea09b..9d7fcfc51 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -66,22 +66,14 @@ def it_should_raise_on_heading_level_out_of_range(self, document): with pytest.raises(ValueError): document.add_heading(level=10) - def it_can_add_an_empty_paragraph(self, add_empty_paragraph_fixture): - document, document_part_, paragraph_ = add_empty_paragraph_fixture - paragraph = document.add_paragraph() - document_part_.add_paragraph.assert_called_once_with() + def it_can_add_a_paragraph(self, add_paragraph_fixture): + document, document_part_, text, style, paragraph_ = ( + add_paragraph_fixture + ) + paragraph = document.add_paragraph(text, style) + document_part_.add_paragraph.assert_called_once_with(text, style) assert paragraph is paragraph_ - def it_can_add_a_paragraph_of_text(self, add_text_paragraph_fixture): - document, text = add_text_paragraph_fixture - paragraph = document.add_paragraph(text) - paragraph.add_run.assert_called_once_with(text) - - def it_can_add_a_styled_paragraph(self, add_styled_paragraph_fixture): - document, style = add_styled_paragraph_fixture - paragraph = document.add_paragraph(style=style) - assert paragraph.style == style - def it_can_add_a_page_break(self, add_page_break_fixture): document, document_part_, paragraph_, run_ = add_page_break_fixture paragraph = document.add_page_break() @@ -177,10 +169,15 @@ def it_creates_styles_part_on_first_access_if_not_present( # fixtures ------------------------------------------------------- - @pytest.fixture - def add_empty_paragraph_fixture( - self, document, document_part_, paragraph_): - return document, document_part_, paragraph_ + @pytest.fixture(params=[ + ('', None), + ('', 'Heading1'), + ('foo\rbar', 'BodyText'), + ]) + def add_paragraph_fixture( + self, request, document, document_part_, paragraph_): + text, style = request.param + return document, document_part_, text, style, paragraph_ @pytest.fixture(params=[0, 1, 2, 5, 9]) def add_heading_fixture( @@ -208,11 +205,6 @@ def add_picture_fixture(self, request, run_, picture_): def add_section_fixture(self, document, start_type_, section_): return document, start_type_, section_ - @pytest.fixture - def add_styled_paragraph_fixture(self, document): - style = 'foobaresque' - return document, style - @pytest.fixture(params=[None, 'LightShading-Accent1', 'foobar']) def add_table_fixture(self, request, document, document_part_, table_): rows, cols = 4, 2 @@ -222,11 +214,6 @@ def add_table_fixture(self, request, document, document_part_, table_): table_ ) - @pytest.fixture - def add_text_paragraph_fixture(self, document): - text = 'foobar\rbarfoo' - return document, text - @pytest.fixture def init_fixture(self, docx_, open_): return docx_, open_ From 45f2c64ff552e82c35f5efe35895f7229705586b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 17 Jul 2014 22:34:23 -0700 Subject: [PATCH 183/809] blkct: introduce BlockItemContainer * base _Body on BlockItemContainer * no substantial inheritence yet, just getting it wired up --- docx/blkcntnr.py | 23 +++++++++++++++++++++++ docx/parts/document.py | 5 +++-- 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 docx/blkcntnr.py diff --git a/docx/blkcntnr.py b/docx/blkcntnr.py new file mode 100644 index 000000000..c80d366dc --- /dev/null +++ b/docx/blkcntnr.py @@ -0,0 +1,23 @@ +# encoding: utf-8 + +""" +Block item container, used by body, cell, header, etc. Block level items are +things like paragraph and table, although there are a few other specialized +ones like structured document tags. +""" + +from __future__ import absolute_import, print_function + +from .shared import Parented + + +class BlockItemContainer(Parented): + """ + Base class for proxy objects that can contain block items, such as _Body, + _Cell, header, footer, footnote, endnote, comment, and text box objects. + Provides the shared functionality to add a block item like a paragraph or + table. + """ + def __init__(self, element, parent): + super(BlockItemContainer, self).__init__(parent) + self._element = element diff --git a/docx/parts/document.py b/docx/parts/document.py index 2eeb77fc4..d7761415e 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -10,6 +10,7 @@ from collections import Sequence +from ..blkcntnr import BlockItemContainer from ..enum.section import WD_SECTION from ..opc.constants import RELATIONSHIP_TYPE as RT from ..opc.package import XmlPart @@ -113,13 +114,13 @@ def tables(self): return self.body.tables -class _Body(Parented): +class _Body(BlockItemContainer): """ Proxy for ```` element in this document, having primarily a container role. """ def __init__(self, body_elm, parent): - super(_Body, self).__init__(parent) + super(_Body, self).__init__(body_elm, parent) self._body = body_elm def add_paragraph(self, text='', style=None): From 72b3d5df17f662d590faeaa7acc9db1d8beae7a8 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 17 Jul 2014 22:58:12 -0700 Subject: [PATCH 184/809] oxml: remove dead code from CT_Body * also fix type in Length class docstring --- docx/oxml/parts/document.py | 18 ------------------ docx/shared.py | 4 ++-- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/docx/oxml/parts/document.py b/docx/oxml/parts/document.py index b64b71d05..ff5eedb91 100644 --- a/docx/oxml/parts/document.py +++ b/docx/oxml/parts/document.py @@ -47,12 +47,6 @@ def add_section_break(self): p.set_sectPr(cloned_sectPr) return sentinel_sectPr - def _insert_p(self, p): - return self._append_blocklevelelt(p) - - def _insert_tbl(self, tbl): - return self._append_blocklevelelt(tbl) - def _new_tbl(self): return CT_Tbl.new() @@ -67,15 +61,3 @@ def clear_content(self): content_elms = self[:] for content_elm in content_elms: self.remove(content_elm) - - def _append_blocklevelelt(self, block_level_elt): - """ - Return *block_level_elt* after appending it to end of - EG_BlockLevelElts sequence. - """ - sectPr = self.sectPr - if sectPr is not None: - sectPr.addprevious(block_level_elt) - else: - self.append(block_level_elt) - return block_level_elt diff --git a/docx/shared.py b/docx/shared.py index 2ea4cf474..f7cd4e147 100644 --- a/docx/shared.py +++ b/docx/shared.py @@ -10,8 +10,8 @@ class Length(int): """ Base class for length constructor classes Inches, Cm, Mm, Px, and Emu. - Behaves as an int count of English Metric Units, 914400 to the inch, - 36000 to the cm. Provides convenience unit conversion methods in the form + Behaves as an int count of English Metric Units, 914,400 to the inch, + 36,000 to the mm. Provides convenience unit conversion methods in the form of read-only properties. Immutable. """ _EMUS_PER_INCH = 914400 From e2d631165f4461ef0fb1f23fb4cd2e832fbb323b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 17 Jul 2014 23:10:47 -0700 Subject: [PATCH 185/809] blkct: add BlockItemContainer.add_paragraph() * remove _Body.add_paragraph(), inheriting that method from BlockItemContainer --- docx/blkcntnr.py | 16 ++++++++++++++++ docx/parts/document.py | 15 --------------- tests/test_blkcntnr.py | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 15 deletions(-) create mode 100644 tests/test_blkcntnr.py diff --git a/docx/blkcntnr.py b/docx/blkcntnr.py index c80d366dc..b1c4c78c1 100644 --- a/docx/blkcntnr.py +++ b/docx/blkcntnr.py @@ -9,6 +9,7 @@ from __future__ import absolute_import, print_function from .shared import Parented +from .text import Paragraph class BlockItemContainer(Parented): @@ -21,3 +22,18 @@ class BlockItemContainer(Parented): def __init__(self, element, parent): super(BlockItemContainer, self).__init__(parent) self._element = element + + 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 + paragraph style *style*. If *style* is |None|, no paragraph style is + applied, which has the same effect as applying the 'Normal' style. + """ + p = self._element.add_p() + paragraph = Paragraph(p, self) + if text: + paragraph.add_run(text) + if style is not None: + paragraph.style = style + return paragraph diff --git a/docx/parts/document.py b/docx/parts/document.py index d7761415e..e21b33ef7 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -123,21 +123,6 @@ def __init__(self, body_elm, parent): super(_Body, self).__init__(body_elm, parent) self._body = body_elm - def add_paragraph(self, text='', style=None): - """ - Return a paragraph newly added to the end of body content, having - *text* in a single run if present, and having paragraph style - *style*. If *style* is |None|, no paragraph style is applied, which - has the same effect as applying the 'Normal' style. - """ - p = self._body.add_p() - paragraph = Paragraph(p, self) - if text: - paragraph.add_run(text) - if style is not None: - paragraph.style = style - return paragraph - def add_table(self, rows, cols): """ Return a table having *rows* rows and *cols* cols, newly appended to diff --git a/tests/test_blkcntnr.py b/tests/test_blkcntnr.py new file mode 100644 index 000000000..f00c72873 --- /dev/null +++ b/tests/test_blkcntnr.py @@ -0,0 +1,41 @@ +# encoding: utf-8 + +""" +Test suite for the docx.blkcntnr (block item container) module +""" + +from __future__ import absolute_import, print_function, unicode_literals + +import pytest + +from docx.blkcntnr import BlockItemContainer +from docx.text import Paragraph + +from .unitutil.cxml import element, xml + + +class DescribeBlockItemContainer(object): + + def it_can_add_a_paragraph(self, add_paragraph_fixture): + blkcntnr, text, style, expected_xml = add_paragraph_fixture + paragraph = blkcntnr.add_paragraph(text, style) + assert blkcntnr._element.xml == expected_xml + assert isinstance(paragraph, Paragraph) + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[ + ('w:body', '', None, + 'w:body/w:p'), + ('w:body', 'foobar', None, + 'w:body/w:p/w:r/w:t"foobar"'), + ('w:body', '', 'Heading1', + 'w:body/w:p/w:pPr/w:pStyle{w:val=Heading1}'), + ('w:body', 'barfoo', 'BodyText', + 'w:body/w:p/(w:pPr/w:pStyle{w:val=BodyText},w:r/w:t"barfoo")'), + ]) + def add_paragraph_fixture(self, request): + blkcntnr_cxml, text, style, after_cxml = request.param + blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) + expected_xml = xml(after_cxml) + return blkcntnr, text, style, expected_xml From 0c293d5aa872cfdea4bf0646f3571f8152b7ac5d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 17 Jul 2014 23:35:19 -0700 Subject: [PATCH 186/809] blkct: add BlockItemContainer.add_table() * remove _Body.add_table(), inheriting that method from BlockItemContainer * refactor Describe_Body.it_can_add_a_table() to use cxml instead of XML builders * retain test for _Body.add_table() to test integration with required oxml elements. --- docx/blkcntnr.py | 14 +++++++ docx/parts/document.py | 13 ------ tests/parts/test_document.py | 78 +++++++++--------------------------- tests/test_blkcntnr.py | 23 +++++++++++ 4 files changed, 55 insertions(+), 73 deletions(-) diff --git a/docx/blkcntnr.py b/docx/blkcntnr.py index b1c4c78c1..f056667e8 100644 --- a/docx/blkcntnr.py +++ b/docx/blkcntnr.py @@ -37,3 +37,17 @@ def add_paragraph(self, text='', style=None): if style is not None: paragraph.style = style return paragraph + + def add_table(self, rows, cols): + """ + Return a newly added table having *rows* rows and *cols* cols, + appended to the content in this container. + """ + from .table import Table + tbl = self._element.add_tbl() + table = Table(tbl, self) + for i in range(cols): + table.add_column() + for i in range(rows): + table.add_row() + return table diff --git a/docx/parts/document.py b/docx/parts/document.py index e21b33ef7..a287be777 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -123,19 +123,6 @@ def __init__(self, body_elm, parent): super(_Body, self).__init__(body_elm, parent) self._body = body_elm - def add_table(self, rows, cols): - """ - Return a table having *rows* rows and *cols* cols, newly appended to - the main document story. - """ - tbl = self._body.add_tbl() - table = Table(tbl, self) - for i in range(cols): - table.add_column() - for i in range(rows): - table.add_row() - return table - def clear_content(self): """ Return this |_Body| instance after clearing it of all content. diff --git a/tests/parts/test_document.py b/tests/parts/test_document.py index 6c0e48978..26d0ff901 100644 --- a/tests/parts/test_document.py +++ b/tests/parts/test_document.py @@ -21,10 +21,7 @@ from docx.text import Paragraph, Run from ..oxml.parts.unitdata.document import a_body, a_document -from ..oxml.unitdata.table import ( - a_gridCol, a_tbl, a_tblGrid, a_tblPr, a_tblW, a_tc, a_tr -) -from ..oxml.unitdata.text import a_p, a_sectPr +from ..oxml.unitdata.text import a_p from ..unitutil.cxml import element, xml from ..unitutil.mock import ( instance_mock, class_mock, loose_mock, method_mock, property_mock @@ -295,9 +292,9 @@ def it_can_add_a_paragraph(self, add_paragraph_fixture): assert isinstance(p, Paragraph) def it_can_add_a_table(self, add_table_fixture): - body, expected_xml = add_table_fixture - table = body.add_table(rows=1, cols=1) - assert body._body.xml == expected_xml + body, rows, cols, expected_xml = add_table_fixture + table = body.add_table(rows, cols) + assert body._element.xml == expected_xml assert isinstance(table, Table) def it_can_clear_itself_of_all_content_it_holds(self, clear_fixture): @@ -335,19 +332,21 @@ def add_paragraph_fixture(self, request): expected_xml = xml(after_cxml) return body, expected_xml - @pytest.fixture(params=[(0, False), (0, True), (1, False), (1, True)]) + @pytest.fixture(params=[ + ('w:body', 0, 0, 'w:body/w:tbl/(w:tblPr/w:tblW{w:type=auto,w:w=0},w:' + 'tblGrid)'), + ('w:body', 1, 0, 'w:body/w:tbl/(w:tblPr/w:tblW{w:type=auto,w:w=0},w:' + 'tblGrid,w:tr)'), + ('w:body', 0, 1, 'w:body/w:tbl/(w:tblPr/w:tblW{w:type=auto,w:w=0},w:' + 'tblGrid/w:gridCol)'), + ('w:body', 1, 1, 'w:body/w:tbl/(w:tblPr/w:tblW{w:type=auto,w:w=0},w:' + 'tblGrid/w:gridCol,w:tr/w:tc/w:p)'), + ]) def add_table_fixture(self, request): - p_count, has_sectPr = request.param - body_bldr = self._body_bldr(p_count=p_count, sectPr=has_sectPr) - body = _Body(body_bldr.element, None) - - tbl_bldr = self._tbl_bldr() - body_bldr = self._body_bldr( - p_count=p_count, tbl_bldr=tbl_bldr, sectPr=has_sectPr - ) - expected_xml = body_bldr.xml() - - return body, expected_xml + body_cxml, rows, cols, after_cxml = request.param + body = _Body(element(body_cxml), None) + expected_xml = xml(after_cxml) + return body, rows, cols, expected_xml @pytest.fixture(params=[ ('w:body', 'w:body'), @@ -369,47 +368,6 @@ def paragraphs_fixture(self): def tables_fixture(self): return _Body(element('w:body/(w:tbl, w:tbl)'), None) - # fixture components --------------------------------------------- - - def _body_bldr(self, p_count=0, tbl_bldr=None, sectPr=False): - body_bldr = a_body().with_nsdecls() - for i in range(p_count): - body_bldr.with_child(a_p()) - if tbl_bldr is not None: - body_bldr.with_child(tbl_bldr) - if sectPr: - body_bldr.with_child(a_sectPr()) - return body_bldr - - def _tbl_bldr(self, rows=1, cols=1): - tblPr_bldr = ( - a_tblPr().with_child( - a_tblW().with_type("auto").with_w(0)) - ) - - tblGrid_bldr = a_tblGrid() - for i in range(cols): - tblGrid_bldr.with_child(a_gridCol()) - - tbl_bldr = a_tbl() - tbl_bldr.with_child(tblPr_bldr) - tbl_bldr.with_child(tblGrid_bldr) - for i in range(rows): - tr_bldr = self._tr_bldr(cols) - tbl_bldr.with_child(tr_bldr) - - return tbl_bldr - - def _tc_bldr(self): - return a_tc().with_child(a_p()) - - def _tr_bldr(self, cols): - tr_bldr = a_tr() - for i in range(cols): - tc_bldr = self._tc_bldr() - tr_bldr.with_child(tc_bldr) - return tr_bldr - class DescribeInlineShapes(object): diff --git a/tests/test_blkcntnr.py b/tests/test_blkcntnr.py index f00c72873..68cde88f4 100644 --- a/tests/test_blkcntnr.py +++ b/tests/test_blkcntnr.py @@ -9,6 +9,7 @@ import pytest from docx.blkcntnr import BlockItemContainer +from docx.table import Table from docx.text import Paragraph from .unitutil.cxml import element, xml @@ -22,6 +23,12 @@ def it_can_add_a_paragraph(self, add_paragraph_fixture): assert blkcntnr._element.xml == expected_xml assert isinstance(paragraph, Paragraph) + def it_can_add_a_table(self, add_table_fixture): + blkcntnr, rows, cols, expected_xml = add_table_fixture + table = blkcntnr.add_table(rows, cols) + assert blkcntnr._element.xml == expected_xml + assert isinstance(table, Table) + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -39,3 +46,19 @@ def add_paragraph_fixture(self, request): blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) expected_xml = xml(after_cxml) return blkcntnr, text, style, expected_xml + + @pytest.fixture(params=[ + ('w:body', 0, 0, 'w:body/w:tbl/(w:tblPr/w:tblW{w:type=auto,w:w=0},w:' + 'tblGrid)'), + ('w:body', 1, 0, 'w:body/w:tbl/(w:tblPr/w:tblW{w:type=auto,w:w=0},w:' + 'tblGrid,w:tr)'), + ('w:body', 0, 1, 'w:body/w:tbl/(w:tblPr/w:tblW{w:type=auto,w:w=0},w:' + 'tblGrid/w:gridCol)'), + ('w:body', 1, 1, 'w:body/w:tbl/(w:tblPr/w:tblW{w:type=auto,w:w=0},w:' + 'tblGrid/w:gridCol,w:tr/w:tc/w:p)'), + ]) + def add_table_fixture(self, request): + blkcntnr_cxml, rows, cols, after_cxml = request.param + blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) + expected_xml = xml(after_cxml) + return blkcntnr, rows, cols, expected_xml From 15d4218cb759009b40341711874dca284e6d6472 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 17 Jul 2014 23:42:21 -0700 Subject: [PATCH 187/809] blkct: add BlockItemContainer.paragraphs * remove implementation from _Body to allow inheritance --- docx/blkcntnr.py | 8 ++++++++ docx/parts/document.py | 5 ----- tests/test_blkcntnr.py | 25 +++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/docx/blkcntnr.py b/docx/blkcntnr.py index f056667e8..ff04a888b 100644 --- a/docx/blkcntnr.py +++ b/docx/blkcntnr.py @@ -51,3 +51,11 @@ def add_table(self, rows, cols): for i in range(rows): table.add_row() return table + + @property + def paragraphs(self): + """ + A list containing the paragraphs in this container, in document + order. Read-only. + """ + return [Paragraph(p, self) for p in self._element.p_lst] diff --git a/docx/parts/document.py b/docx/parts/document.py index a287be777..b2ea918db 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -18,7 +18,6 @@ from ..shape import InlineShape from ..shared import lazyproperty, Parented from ..table import Table -from ..text import Paragraph class DocumentPart(XmlPart): @@ -132,10 +131,6 @@ def clear_content(self): self._body.clear_content() return self - @property - def paragraphs(self): - return [Paragraph(p, self) for p in self._body.p_lst] - @property def tables(self): """ diff --git a/tests/test_blkcntnr.py b/tests/test_blkcntnr.py index 68cde88f4..357f2f84d 100644 --- a/tests/test_blkcntnr.py +++ b/tests/test_blkcntnr.py @@ -29,6 +29,19 @@ def it_can_add_a_table(self, add_table_fixture): assert blkcntnr._element.xml == expected_xml assert isinstance(table, Table) + 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): + assert isinstance(paragraph, Paragraph) + assert paragraphs[idx] is paragraph + count += 1 + assert count == expected_count + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -62,3 +75,15 @@ def add_table_fixture(self, request): blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) expected_xml = xml(after_cxml) return blkcntnr, rows, cols, expected_xml + + @pytest.fixture(params=[ + ('w:body', 0), + ('w:body/w:p', 1), + ('w:body/(w:p,w:p)', 2), + ('w:body/(w:p,w:tbl)', 1), + ('w:body/(w:p,w:tbl,w:p)', 2), + ]) + def paragraphs_fixture(self, request): + blkcntnr_cxml, expected_count = request.param + blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) + return blkcntnr, expected_count From b9bb31629166a0d26de6ffe92b879505b514be5b Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 17 Jul 2014 23:45:45 -0700 Subject: [PATCH 188/809] blkct: add BlockItemContainer.tables * remove implementation from _Body to allow inheritance --- docx/blkcntnr.py | 9 +++++++++ docx/parts/document.py | 9 --------- tests/test_blkcntnr.py | 24 ++++++++++++++++++++++++ 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/docx/blkcntnr.py b/docx/blkcntnr.py index ff04a888b..b11f3a50d 100644 --- a/docx/blkcntnr.py +++ b/docx/blkcntnr.py @@ -59,3 +59,12 @@ def paragraphs(self): order. Read-only. """ return [Paragraph(p, self) for p in self._element.p_lst] + + @property + def tables(self): + """ + A list containing the tables in this container, in document order. + Read-only. + """ + from .table import Table + return [Table(tbl, self) for tbl in self._element.tbl_lst] diff --git a/docx/parts/document.py b/docx/parts/document.py index b2ea918db..e7ff08e8b 100644 --- a/docx/parts/document.py +++ b/docx/parts/document.py @@ -17,7 +17,6 @@ from ..section import Section from ..shape import InlineShape from ..shared import lazyproperty, Parented -from ..table import Table class DocumentPart(XmlPart): @@ -131,14 +130,6 @@ def clear_content(self): self._body.clear_content() return self - @property - def tables(self): - """ - A sequence containing all the tables in the document, in the order - they appear. - """ - return [Table(tbl, self) for tbl in self._body.tbl_lst] - class InlineShapes(Parented): """ diff --git a/tests/test_blkcntnr.py b/tests/test_blkcntnr.py index 357f2f84d..b8de5e400 100644 --- a/tests/test_blkcntnr.py +++ b/tests/test_blkcntnr.py @@ -42,6 +42,18 @@ def it_provides_access_to_the_paragraphs_it_contains( count += 1 assert count == expected_count + def it_provides_access_to_the_tables_it_contains(self, tables_fixture): + # 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): + assert isinstance(table, Table) + assert tables[idx] is table + count += 1 + assert count == expected_count + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -87,3 +99,15 @@ 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), + ]) + def tables_fixture(self, request): + blkcntnr_cxml, expected_count = request.param + blkcntnr = BlockItemContainer(element(blkcntnr_cxml), None) + return blkcntnr, expected_count From dc942433cf81620e36f26fc55aab57e5b1183587 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 17 Jul 2014 23:55:14 -0700 Subject: [PATCH 189/809] tbl: base _Cell on BlockItemContainer * use super() on _Cell.paragraphs rather than inherit so a custom docstring can be provided. --- docx/table.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docx/table.py b/docx/table.py index 26e8dc5c9..b83fc204b 100644 --- a/docx/table.py +++ b/docx/table.py @@ -6,8 +6,8 @@ from __future__ import absolute_import, print_function, unicode_literals +from .blkcntnr import BlockItemContainer from .shared import lazyproperty, Parented, write_only_property -from .text import Paragraph class Table(Parented): @@ -92,22 +92,22 @@ def _tblPr(self): return self._tbl.tblPr -class _Cell(Parented): +class _Cell(BlockItemContainer): """ Table cell """ def __init__(self, tc, parent): - super(_Cell, self).__init__(parent) + super(_Cell, self).__init__(tc, parent) self._tc = tc @property def paragraphs(self): """ List of paragraphs in the cell. A table cell is required to contain - at least one block-level element. By default this is a single - paragraph. + at least one block-level element and end with a paragraph. By + default, a new cell contains a single paragraph. """ - return [Paragraph(p, self) for p in self._tc.p_lst] + return super(_Cell, self).paragraphs @write_only_property def text(self, text): From 56a5af9fe265c3ea3919dff9f3a2496e6de4f87c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 18 Jul 2014 00:04:18 -0700 Subject: [PATCH 190/809] tbl: add _Cell.add_paragraph() * use super() rather than direct inheritance to allow custom docstring --- docx/table.py | 14 ++++++++++++++ tests/test_table.py | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/docx/table.py b/docx/table.py index b83fc204b..7c2bcecbb 100644 --- a/docx/table.py +++ b/docx/table.py @@ -100,6 +100,20 @@ def __init__(self, tc, parent): super(_Cell, self).__init__(tc, parent) self._tc = tc + def add_paragraph(self, text='', style=None): + """ + Return a paragraph newly added to the end of the content in this + cell. If present, *text* is added to the paragraph in a single run. + If specified, the paragraph style *style* is applied. If *style* is + not specified or is |None|, the result is as though the 'Normal' + style was applied. Note that the formatting of text in a cell can be + influenced by the table style. *text* can contain tab (``\\t``) + characters, which are converted to the appropriate XML form for + a tab. *text* can also include newline (``\\n``) or carriage return + (``\\r``) characters, each of which is converted to a line break. + """ + return super(_Cell, self).add_paragraph(text, style) + @property def paragraphs(self): """ diff --git a/tests/test_table.py b/tests/test_table.py index 736dbb1b2..0fbd5a6b1 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -151,6 +151,12 @@ def table(self): class Describe_Cell(object): + def it_can_add_a_paragraph(self, add_paragraph_fixture): + cell, expected_xml = add_paragraph_fixture + p = cell.add_paragraph() + assert cell._tc.xml == expected_xml + assert isinstance(p, Paragraph) + def it_provides_access_to_the_paragraphs_it_contains( self, paragraphs_fixture): cell = paragraphs_fixture @@ -181,6 +187,17 @@ def it_can_change_its_width(self, width_set_fixture): # fixtures ------------------------------------------------------- + @pytest.fixture(params=[ + ('w:tc', 'w:tc/w:p'), + ('w:tc/w:p', 'w:tc/(w:p, w:p)'), + ('w:tc/w:tbl', 'w:tc/(w:tbl, w:p)'), + ]) + def add_paragraph_fixture(self, request): + tc_cxml, after_tc_cxml = request.param + cell = _Cell(element(tc_cxml), None) + expected_xml = xml(after_tc_cxml) + return cell, expected_xml + @pytest.fixture def paragraphs_fixture(self): return _Cell(element('w:tc/(w:p, w:p)'), None) From 2214533fe1ef712c4ddc3038e6755307be626af7 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 18 Jul 2014 00:21:08 -0700 Subject: [PATCH 191/809] tbl: add _Cell.add_table() --- docx/oxml/table.py | 8 ++++++-- docx/table.py | 11 +++++++++++ tests/test_table.py | 21 +++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 9f4b20e2a..f2fbd540f 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -165,6 +165,7 @@ class CT_Tc(BaseOxmlElement): """ tcPr = ZeroOrOne('w:tcPr') # bunches of successors, overriding insert p = OneOrMore('w:p') + tbl = OneOrMore('w:tbl') def _insert_tcPr(self, tcPr): """ @@ -175,13 +176,16 @@ def _insert_tcPr(self, tcPr): self.insert(0, tcPr) return tcPr + def _new_tbl(self): + return CT_Tbl.new() + def clear_content(self): """ Remove all content child elements, preserving the ```` element if present. Note that this leaves the ```` element in an invalid state because it doesn't contain at least one block-level - element. It's up to the caller to add a ```` or ```` - child element. + element. It's up to the caller to add a ````child element as the + last content element. """ new_children = [] tcPr = self.tcPr diff --git a/docx/table.py b/docx/table.py index 7c2bcecbb..8c70d8af4 100644 --- a/docx/table.py +++ b/docx/table.py @@ -114,6 +114,17 @@ def add_paragraph(self, text='', style=None): """ return super(_Cell, self).add_paragraph(text, style) + def add_table(self, rows, cols): + """ + Return a table newly added to this cell after any existing cell + content, having *rows* rows and *cols* columns. An empty paragraph is + added after the table because Word requires a paragraph element as + the last element in every cell. + """ + new_table = super(_Cell, self).add_table(rows, cols) + self.add_paragraph() + return new_table + @property def paragraphs(self): """ diff --git a/tests/test_table.py b/tests/test_table.py index 0fbd5a6b1..2d4803784 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -157,6 +157,12 @@ def it_can_add_a_paragraph(self, add_paragraph_fixture): assert cell._tc.xml == expected_xml assert isinstance(p, Paragraph) + def it_can_add_a_table(self, add_table_fixture): + cell, expected_xml = add_table_fixture + table = cell.add_table(rows=0, cols=0) + assert cell._tc.xml == expected_xml + assert isinstance(table, Table) + def it_provides_access_to_the_paragraphs_it_contains( self, paragraphs_fixture): cell = paragraphs_fixture @@ -198,6 +204,21 @@ def add_paragraph_fixture(self, request): expected_xml = xml(after_tc_cxml) return cell, expected_xml + @pytest.fixture(params=[ + ('w:tc', 'w:tc/(w:tbl'), + ('w:tc/w:p', 'w:tc/(w:p, w:tbl'), + ]) + def add_table_fixture(self, request): + tc_cxml, after_tc_cxml = request.param + # the table has some overhead elements, also a blank para after since + # it's in a cell. + after_tc_cxml += ( + '/(w:tblPr/w:tblW{w:type=auto,w:w=0},w:tblGrid),w:p)' + ) + cell = _Cell(element(tc_cxml), None) + expected_xml = xml(after_tc_cxml) + return cell, expected_xml + @pytest.fixture def paragraphs_fixture(self): return _Cell(element('w:tc/(w:p, w:p)'), None) From 8364dad0cc7b23f3c2efd297c4ef6aa3781ad490 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 18 Jul 2014 00:29:27 -0700 Subject: [PATCH 192/809] tbl: add _Cell.tables --- docx/table.py | 9 ++++++++- tests/test_table.py | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/docx/table.py b/docx/table.py index 8c70d8af4..544553b1e 100644 --- a/docx/table.py +++ b/docx/table.py @@ -130,10 +130,17 @@ def paragraphs(self): """ List of paragraphs in the cell. A table cell is required to contain at least one block-level element and end with a paragraph. By - default, a new cell contains a single paragraph. + default, a new cell contains a single paragraph. Read-only """ return super(_Cell, self).paragraphs + @property + def tables(self): + """ + List of tables in the cell, in the order they appear. Read-only. + """ + return super(_Cell, self).tables + @write_only_property def text(self, text): """ diff --git a/tests/test_table.py b/tests/test_table.py index 2d4803784..4ecf55d19 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -175,6 +175,18 @@ def it_provides_access_to_the_paragraphs_it_contains( count += 1 assert count == 2 + def it_provides_access_to_the_tables_it_contains(self, tables_fixture): + # test len(), iterable, and indexed access + cell, expected_count = tables_fixture + tables = cell.tables + assert len(tables) == expected_count + count = 0 + for idx, table in enumerate(tables): + assert isinstance(table, Table) + assert tables[idx] is table + count += 1 + assert count == expected_count + def it_can_replace_its_content_with_a_string_of_text( self, text_set_fixture): cell, text, expected_xml = text_set_fixture @@ -223,6 +235,18 @@ def add_table_fixture(self, request): def paragraphs_fixture(self): return _Cell(element('w:tc/(w:p, w:p)'), None) + @pytest.fixture(params=[ + ('w:tc', 0), + ('w:tc/w:tbl', 1), + ('w:tc/(w:tbl,w:tbl)', 2), + ('w:tc/(w:p,w:tbl)', 1), + ('w:tc/(w:tbl,w:tbl,w:p)', 2), + ]) + def tables_fixture(self, request): + cell_cxml, expected_count = request.param + cell = _Cell(element(cell_cxml), None) + return cell, expected_count + @pytest.fixture(params=[ ('w:tc/w:p', 'foobar', 'w:tc/w:p/w:r/w:t"foobar"'), From 83315deb9c3a0f844a60e35094814d14b03978a7 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 14 Jul 2014 13:26:37 -0700 Subject: [PATCH 193/809] opc: add content type for .docx package Needed to set Content-Type header in an HTTP Response object carrying a .docx payload. --- docx/opc/constants.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docx/opc/constants.py b/docx/opc/constants.py index c97568048..b90aa394a 100644 --- a/docx/opc/constants.py +++ b/docx/opc/constants.py @@ -268,6 +268,10 @@ class CONTENT_TYPE(object): 'application/vnd.openxmlformats-officedocument.wordprocessingml.comm' 'ents+xml' ) + WML_DOCUMENT = ( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.docu' + 'ment' + ) WML_DOCUMENT_GLOSSARY = ( 'application/vnd.openxmlformats-officedocument.wordprocessingml.docu' 'ment.glossary+xml' From 5886a483e7823051711870bbd6a7ca94eba46834 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 18 Jul 2014 00:55:10 -0700 Subject: [PATCH 194/809] release: prepare v0.7.4 release --- HISTORY.rst | 8 ++++++++ docx/__init__.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index fa825a9ae..925cd95be 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,14 @@ Release History --------------- +0.7.4 (2014-07-18) +++++++++++++++++++ + +- Add feature #45: _Cell.add_table() +- Add feature #76: _Cell.add_paragraph() +- Add _Cell.tables property (read-only) + + 0.7.3 (2014-07-14) ++++++++++++++++++ diff --git a/docx/__init__.py b/docx/__init__.py index 4a8378d6f..4e4fdfda0 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.7.3' +__version__ = '0.7.4' # register custom Part classes with opc package reader From b155177f6282cdbf1b1aa3d68044ad5571e152a3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 18 Aug 2014 20:48:16 -0700 Subject: [PATCH 195/809] config: move flake8 targets to tox.ini --- tox.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tox.ini b/tox.ini index d463f248f..014fd8dda 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,9 @@ # # Configuration for tox and pytest +[flake8] +exclude = dist,docs,*.egg-info,.git,ref,_scratch,.tox + [pytest] norecursedirs = doc docx *.egg-info features .git ref _scratch .tox python_files = test_*.py From 58b323938644f04e94e63928b1c937b16ee4c808 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 23 Sep 2014 22:33:56 -0700 Subject: [PATCH 196/809] fix: correct error message template --- docx/oxml/parts/styles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docx/oxml/parts/styles.py b/docx/oxml/parts/styles.py index ed3054f13..7fea25a01 100644 --- a/docx/oxml/parts/styles.py +++ b/docx/oxml/parts/styles.py @@ -32,4 +32,4 @@ def style_having_styleId(self, styleId): try: return self.xpath(xpath)[0] except IndexError: - raise KeyError('no element with styleId %d' % styleId) + raise KeyError('no element with styleId %s' % styleId) From acc4e3e390b988b96a371cf0494e8b12344b5d87 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Mon, 11 Aug 2014 00:32:17 -0400 Subject: [PATCH 197/809] doc: add cell.merge feature analysis --- docs/dev/analysis/features/cell-merge.rst | 572 ++++++++++++++++++++++ docs/dev/analysis/index.rst | 1 + 2 files changed, 573 insertions(+) create mode 100644 docs/dev/analysis/features/cell-merge.rst diff --git a/docs/dev/analysis/features/cell-merge.rst b/docs/dev/analysis/features/cell-merge.rst new file mode 100644 index 000000000..2b432dfbf --- /dev/null +++ b/docs/dev/analysis/features/cell-merge.rst @@ -0,0 +1,572 @@ + +Table - Merge Cells +=================== + +Word allows contiguous table cells to be merged, such that two or more cells +appear to be a single cell. Cells can be merged horizontally (spanning +multple columns) or vertically (spanning multiple rows). Cells can also be +merged both horizontally and vertically at the same time, producing a cell +that spans both rows and columns. Only rectangular ranges of cells can be +merged. + + +Table diagrams +-------------- + +Diagrams like the one below are used to depict tables in this analysis. +Horizontal spans are depicted as a continuous horizontal cell without +vertical dividers within the span. Vertical spans are depicted as a vertical +sequence of cells of the same width where continuation cells are separated by +a dashed top border and contain a caret ('^') to symbolize the continuation +of the cell above. Cell 'addresses' are depicted at the column and row grid +lines. This is conceptually convenient as it reuses the notion of list +indices (and slices) and makes certain operations more intuitive to specify. +The merged cell `A` below has top, left, bottom, and right values of 0, 0, 2, +and 2 respectively:: + + \ 0 1 2 3 + 0 +---+---+---+ + | A | | + 1 + - - - +---+ + | ^ | | + 2 +---+---+---+ + | | | | + 3 +---+---+---+ + + +Basic cell access protocol +-------------------------- + +There are three ways to access a table cell: + +* ``Table.cell(row_idx, col_idx)`` +* ``Row.cells[col_idx]`` +* ``Column.cells[col_idx]`` + + +Accessing the middle cell of a 3 x 3 table:: + + >>> table = document.add_table(3, 3) + >>> middle_cell = table.cell(1, 1) + >>> table.rows[1].cells[1] == middle_cell + True + >>> table.columns[1].cells[1] == middle_cell + True + + +Basic merge protocol +-------------------- + +A merge is specified using two diagonal cells:: + + >>> table = document.add_table(3, 3) + >>> a = table.cells(0, 0) + >>> b = table.cells(1, 1) + >>> A = a.merge(b) + +:: + + \ 0 1 2 3 + 0 +---+---+---+ +---+---+---+ + | a | | | | A | | + 1 +---+---+---+ + - - - +---+ + | | b | | --> | ^ | | + 2 +---+---+---+ +---+---+---+ + | | | | | | | | + 3 +---+---+---+ +---+---+---+ + + +Accessing a merged cell +----------------------- + +A cell is accessed by its "layout grid" position regardless of any spans that +may be present. A grid address that falls in a span returns the top-leftmost +cell in that span. This means a span has as many addresses as layout grid +cells it spans. For example, the merged cell `A` above can be addressed as +(0, 0), (0, 1), (1, 0), or (1, 1). This addressing scheme leads to desirable +access behaviors when spans are present in the table. + +The length of Row.cells is always equal to the number of grid columns, +regardless of any spans that are present. Likewise, the length of +Column.cells is always equal to the number of table rows, regardless of any +spans. + +:: + + >>> table = document.add_table(2, 3) + >>> row = table.rows[0] + >>> len(row.cells) + 3 + >>> row.cells[0] == row.cells[1] + False + + >>> a, b = row.cells[:2] + >>> a.merge(b) + + >>> len(row.cells) + 3 + >>> row.cells[0] == row.cells[1] + True + +:: + + \ 0 1 2 3 + 0 +---+---+---+ +---+---+---+ + | a | b | | | A | | + 1 +---+---+---+ --> +---+---+---+ + | | | | | | | | + 2 +---+---+---+ +---+---+---+ + + +Cell content behavior on merge +------------------------------ + +When two or more cells are merged, any existing content is concatenated and +placed in the resulting merged cell. Content from each original cell is +separated from that in the prior original cell by a paragraph mark. An +original cell having no content is skipped in the contatenation process. In +Python, the procedure would look roughly like this:: + + merged_cell_text = '\n'.join( + cell.text for cell in original_cells if cell.text + ) + +Merging four cells with content ``'a'``, ``'b'``, ``''``, and ``'d'`` +respectively results in a merged cell having text ``'a\nb\nd'``. + + +Cell size behavior on merge +--------------------------- + +Cell width and height, if present, are added when cells are merged:: + + >>> a, b = row.cells[:2] + >>> a.width.inches, b.width.inches + (1.0, 1.0) + >>> A = a.merge(b) + >>> A.width.inches + 2.0 + + +Removing a redundant row or column +---------------------------------- + +**Collapsing a column.** When all cells in a grid column share the same +``w:gridSpan`` specification, the spanned columns can be collapsed into +a single column by removing the ``w:gridSpan`` attributes. + + +Word behavior +------------- + +* Row and Column access in the MS API just plain breaks when the table is not + uniform. `Table.Rows(n)` and `Cell.Row` raise `EnvironmentError` when + a table contains a vertical span, and `Table.Columns(n)` and `Cell.Column` + unconditionally raise `EnvironmentError` when the table contains + a horizontal span. We can do better. + +* `Table.Cell(n, m)` works on any non-uniform table, although it uses + a *visual grid* that greatly complicates access. It raises an error for `n` + or `m` out of visual range, and provides no way other than try/except to + determine what that visual range is, since `Row.Count` and `Column.Count` + are unavailable. + +* In a merge operation, the text of the continuation cells is appended to + that of the origin cell as separate paragraph(s). + +* If a merge range contains previously merged cells, the range must + completely enclose the merged cells. + +* Word resizes a table (adds rows) when a cell is referenced by an + out-of-bounds row index. If the column identifier is out of bounds, an + exception is raised. This behavior will not be implemented in |docx|. + + +Glossary +-------- + +layout grid + The regular two-dimensional matrix of rows and columns that determines + the layout of cells in the table. The grid is primarily defined by the + `w:gridCol` elements that define the layout columns for the table. Each + row essentially duplicates that layout for an additional row, although + its height can differ from other rows. Every actual cell in the table + must begin and end on a layout grid "line", whether the cell is merged or + not. + +span + The single "combined" cell occupying the area of a set of merged cells. + +skipped cell + The WordprocessingML (WML) spec allows for 'skipped' cells, where + a layout cell location contains no actual cell. I can't find a way to + make a table like this using the Word UI and haven't experimented yet to + see whether Word will load one constructed by hand in the XML. + +uniform table + A table in which each cell corresponds exactly to a layout cell. + A uniform table contains no spans or skipped cells. + +non-uniform table + A table that contains one or more spans, such that not every cell + corresponds to a single layout cell. I suppose it would apply when there + was one or more skipped cells too, but in this analysis the term is only + used to indicate a table with one or more spans. + +uniform cell + A cell not part of a span, occupying a single cell in the layout grid. + +origin cell + The top-leftmost cell in a span. Contrast with *continuation cell*. + +continuation cell + A layout cell that has been subsumed into a span. A continuation cell is + mostly an abstract concept, although a actual `w:tc` element will always + exist in the XML for each continuation cell in a vertical span. + + +Understanding merge XML intuitively +----------------------------------- + +A key insight is that merged cells always look like the diagram below. +Horizontal spans are accomplished with a single `w:tc` element in each row, +using the `gridSpan` attribute to span additional grid columns. Vertical +spans are accomplished with an identical cell in each continuation row, +having the same `gridSpan` value, and having vMerge set to `continue` (the +default). These vertical continuation cells are depicted in the diagrams +below with a dashed top border and a caret ('^') in the left-most grid column +to symbolize the continuation of the cell above.:: + + \ 0 1 2 3 + 0 +---+---+---+ + | A | | + 1 + - - - +---+ + | ^ | | + 2 +---+---+---+ + | | | | + 3 +---+---+---+ + +.. highlight:: xml + +The table depicted above corresponds to this XML (minimized for clarity):: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +XML Semantics +------------- + +In a horizontal merge, the ```` attribute indicates the +number of columns the cell should span. Only the leftmost cell is preserved; +the remaining cells in the merge are deleted. + +For merging vertically, the ``w:vMerge`` table cell property of the uppermost +cell of the column is set to the value "restart" of type ``w:ST_Merge``. The +following, lower cells included in the vertical merge must have the +``w:vMerge`` element present in their cell property (``w:TcPr``) element. Its +value should be set to "continue", although it is not necessary to +explicitely define it, as it is the default value. A vertical merge ends as +soon as a cell ``w:TcPr`` element lacks the ``w:vMerge`` element. Similarly +to the ``w:gridSpan`` element, the ``w:vMerge`` elements are only required +when the table's layout is not uniform across its different columns. In the +case it is, only the topmost cell is kept; the other lower cells in the +merged area are deleted along with their ``w:vMerge`` elements and the +``w:trHeight`` table row property is used to specify the combined height of +the merged cells. + + +len() implementation for Row.cells and Column.cells +--------------------------------------------------- + +Each ``Row`` and ``Column`` object provides access to the collection of cells +it contains. The length of these cell collections is unaffected by the +presence of merged cells. + +`len()` always bases its count on the layout grid, as though there were no +merged cells. + +* ``len(Table.columns)`` is the number of `w:gridCol` elements, representing + the number of grid columns, without regard to the presence of merged cells + in the table. + +* ``len(Table.rows)`` is the number of `w:tr` elements, regardless of any + merged cells that may be present in the table. + +* ``len(Row.cells)`` is the number of grid columns, regardless of whether any + cells in the row are merged. + +* ``len(Column.cells)`` is the number of rows in the table, regardless of + whether any cells in the column are merged. + + +Merging a cell already containing a span +---------------------------------------- + +One or both of the "diagonal corner" cells in a merge operation may itself be +a merged cell, as long as the specified region is rectangular. + +For example:: + + \ 0 1 2 3 + +---+---+---+---+ +---+---+---+---+ + 0 | a | b | | | a\nb\nC | | + + - - - +---+---+ + - - - - - +---+ + 1 | ^ | C | | | ^ | | + +---+---+---+---+ --> +---+---+---+---+ + 2 | | | | | | | | | | + +---+---+---+---+ +---+---+---+---+ + 3 | | | | | | | | | | + +---+---+---+---+ +---+---+---+---+ + + cell(0, 0).merge(cell(1, 2)) + +or:: + + 0 1 2 3 4 + +---+---+---+---+---+ +---+---+---+---+---+ + 0 | a | b | c | | | abcD | | + + - - - +---+---+---+ + - - - - - - - +---+ + 1 | ^ | D | | | ^ | | + +---+---+---+---+---+ --> +---+---+---+---+---+ + 2 | | | | | | | | | | | | + +---+ - - - +---+---+ +---+---+---+---+---+ + 3 | | | | | | | | | | | | + +---+---+---+---+---+ +---+---+---+---+---+ + + cell(0, 0).merge(cell(1, 2)) + + +Conversely, either of these two merge operations would be illegal:: + + \ 0 1 2 3 4 0 1 2 3 4 + 0 +---+---+---+---+ 0 +---+---+---+---+ + | | | b | | | | | | | + 1 +---+---+ - +---+ 1 +---+---+---+---+ + | | a | ^ | | | | a | | | + 2 +---+---+ - +---+ 2 +---+---+---+---+ + | | | ^ | | | b | | + 3 +---+---+---+---+ 3 +---+---+---+---+ + | | | | | | | | | | + 4 +---+---+---+---+ 4 +---+---+---+---+ + + a.merge(b) + + +General algorithm +~~~~~~~~~~~~~~~~~ + +* find top-left and target width, height +* for each tr in target height, tc.grow_right(target_width) + + +Specimen XML +------------ + +.. highlight:: xml + +A 3 x 3 table where an area defined by the 2 x 2 topleft cells has been +merged, demonstrating the combined use of the ``w:gridSpan`` as well as the +``w:vMerge`` elements, as produced by Word:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Schema excerpt +-------------- + +.. highlight:: xml + +:: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Open Issues +----------- + +* Does Word allow "skipped" cells at the beginning of a row (`w:gridBefore` + element)? These are described in the spec, but I don't see a way in the + Word UI to create such a table. + + +Ressources +---------- + +* `Cell.Merge Method on MSDN`_ + +.. _`Cell.Merge Method on MSDN`: + http://msdn.microsoft.com/en-us/library/office/ff821310%28v=office.15%29.aspx + +Relevant sections in the ISO Spec +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +* 17.4.17 gridSpan (Grid Columns Spanned by Current Table Cell) +* 17.4.84 vMerge (Vertically Merged Cell) +* 17.18.57 ST_Merge (Merged Cell Type) diff --git a/docs/dev/analysis/index.rst b/docs/dev/analysis/index.rst index 7e4d7589e..49cdeda8e 100644 --- a/docs/dev/analysis/index.rst +++ b/docs/dev/analysis/index.rst @@ -10,6 +10,7 @@ Feature Analysis .. toctree:: :maxdepth: 1 + features/cell-merge features/table features/table-props features/table-cell From 210d7f124dcf8c84edbd39d168f47d81cc925bfd Mon Sep 17 00:00:00 2001 From: Apteryks Date: Sat, 1 Nov 2014 00:44:03 -0700 Subject: [PATCH 198/809] acpt: add scenarios for cell access * refactor tbl-item-access to remove cell access scenarios --- features/steps/table.py | 135 +++++------------- .../steps/test_files/tbl-cell-access.docx | Bin 0 -> 36051 bytes features/tbl-cell-access.feature | 42 ++++++ features/tbl-item-access.feature | 30 +--- 4 files changed, 78 insertions(+), 129 deletions(-) create mode 100644 features/steps/test_files/tbl-cell-access.docx create mode 100644 features/tbl-cell-access.feature diff --git a/features/steps/table.py b/features/steps/table.py index 2d7aa37dc..19c08c31d 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -10,9 +10,7 @@ from docx import Document from docx.shared import Inches -from docx.table import ( - _Cell, _Column, _ColumnCells, _Columns, _Row, _RowCells, _Rows -) +from docx.table import _Column, _Columns, _Row, _Rows from helpers import test_docx @@ -24,11 +22,16 @@ def given_a_2x2_table(context): context.table_ = Document().add_table(rows=2, cols=2) -@given('a column cell collection having two cells') -def given_a_column_cell_collection_having_two_cells(context): - docx_path = test_docx('blk-containing-table') - document = Document(docx_path) - context.cells = document.tables[0].columns[0].cells +@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, + }[span_state] + document = Document(test_docx('tbl-cell-access')) + context.table_ = document.tables[table_idx] @given('a column collection having two columns') @@ -38,13 +41,6 @@ def given_a_column_collection_having_two_columns(context): context.columns = document.tables[0].columns -@given('a row cell collection having two cells') -def given_a_row_cell_collection_having_two_cells(context): - docx_path = test_docx('blk-containing-table') - document = Document(docx_path) - context.cells = document.tables[0].rows[0].cells - - @given('a row collection having two rows') def given_a_row_collection_having_two_rows(context): docx_path = test_docx('blk-containing-table') @@ -111,20 +107,6 @@ def given_a_table_having_two_rows(context): context.table_ = document.tables[0] -@given('a table column having two cells') -def given_a_table_column_having_two_cells(context): - docx_path = test_docx('blk-containing-table') - document = Document(docx_path) - context.column = document.tables[0].columns[0] - - -@given('a table row having two cells') -def given_a_table_row_having_two_cells(context): - docx_path = test_docx('blk-containing-table') - document = Document(docx_path) - context.row = document.tables[0].rows[0] - - # when ===================================================== @when('I add a column to the table') @@ -166,15 +148,6 @@ def when_I_set_the_table_autofit_to_setting(context, setting): # then ===================================================== -@then('I can access a cell using its row and column indices') -def then_can_access_cell_using_its_row_and_col_indices(context): - table = context.table_ - for row_idx in range(2): - for col_idx in range(2): - cell = table.cell(row_idx, col_idx) - assert isinstance(cell, _Cell) - - @then('I can access a collection column by index') def then_can_access_collection_column_by_index(context): columns = context.columns @@ -191,36 +164,6 @@ def then_can_access_collection_row_by_index(context): assert isinstance(row, _Row) -@then('I can access a column cell by index') -def then_can_access_column_cell_by_index(context): - cells = context.cells - for idx in range(2): - cell = cells[idx] - assert isinstance(cell, _Cell) - - -@then('I can access a row cell by index') -def then_can_access_row_cell_by_index(context): - cells = context.cells - for idx in range(2): - cell = cells[idx] - assert isinstance(cell, _Cell) - - -@then('I can access the cell collection of the column') -def then_can_access_cell_collection_of_column(context): - column = context.column - cells = column.cells - assert isinstance(cells, _ColumnCells) - - -@then('I can access the cell collection of the row') -def then_can_access_cell_collection_of_row(context): - row = context.row - cells = row.cells - assert isinstance(cells, _RowCells) - - @then('I can access the column collection of the table') def then_can_access_column_collection_of_table(context): table = context.table_ @@ -235,20 +178,6 @@ def then_can_access_row_collection_of_table(context): assert isinstance(rows, _Rows) -@then('I can get the length of the column cell collection') -def then_can_get_length_of_column_cell_collection(context): - column = context.column - cells = column.cells - assert len(cells) == 2 - - -@then('I can get the length of the row cell collection') -def then_can_get_length_of_row_cell_collection(context): - row = context.row - cells = row.cells - assert len(cells) == 2 - - @then('I can get the table style name') def then_can_get_table_style_name(context): table = context.table_ @@ -256,16 +185,6 @@ def then_can_get_table_style_name(context): assert table.style == 'LightShading-Accent1', msg -@then('I can iterate over the column cells') -def then_can_iterate_over_the_column_cells(context): - cells = context.cells - actual_count = 0 - for cell in cells: - actual_count += 1 - assert isinstance(cell, _Cell) - assert actual_count == 2 - - @then('I can iterate over the column collection') def then_can_iterate_over_column_collection(context): columns = context.columns @@ -276,16 +195,6 @@ def then_can_iterate_over_column_collection(context): assert actual_count == 2 -@then('I can iterate over the row cells') -def then_can_iterate_over_the_row_cells(context): - cells = context.cells - actual_count = 0 - for cell in cells: - actual_count += 1 - assert isinstance(cell, _Cell) - assert actual_count == 2 - - @then('I can iterate over the row collection') def then_can_iterate_over_row_collection(context): rows = context.rows @@ -296,6 +205,21 @@ def then_can_iterate_over_row_collection(context): assert actual_count == 2 +@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 + + +@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 + + @then('the length of the column collection is 2') def then_len_of_column_collection_is_2(context): columns = context.table_.columns @@ -342,6 +266,13 @@ def then_the_reported_width_of_the_cell_is_width(context, width): ) +@then('the row cells text is {expected_text}') +def then_the_row_cells_text_is_expected_text(context, expected_text): + 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 + + @then('the table style matches the name I applied') def then_table_style_matches_name_applied(context): table = context.table_ diff --git a/features/steps/test_files/tbl-cell-access.docx b/features/steps/test_files/tbl-cell-access.docx new file mode 100644 index 0000000000000000000000000000000000000000..b3c90d94b042afa939e9a09e8b1298d8c98caee0 GIT binary patch literal 36051 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 B39A4A literal 0 HcmV?d00001 diff --git a/features/tbl-cell-access.feature b/features/tbl-cell-access.feature new file mode 100644 index 000000000..7dbd1a6b0 --- /dev/null +++ b/features/tbl-cell-access.feature @@ -0,0 +1,42 @@ +Feature: Access table cells + In order to access individual cells in a table + As a developer using python-docx + I need a way to access a cell from a table, row, or column + + @wip + Scenario Outline: Access cell sequence of a row + Given a 3x3 table having + Then the row cells text is + + Examples: Reported row cell contents + | span-state | expected-text | + | only uniform cells | 1 2 3 4 5 6 7 8 9 | + | a horizontal span | 1 2 3 4 4 6 7 8 9 | + | a vertical span | 1 2 3 4 5 6 7 5 9 | + | a combined span | 1 2 3 4 4 6 4 4 9 | + + + @wip + Scenario Outline: Access cell sequence of a column + Given a 3x3 table having + Then the column cells text is + + Examples: Reported column cell contents + | span-state | expected-text | + | only uniform cells | 1 4 7 2 5 8 3 6 9 | + | a horizontal span | 1 4 7 2 4 8 3 6 9 | + | a vertical span | 1 4 7 2 5 5 3 6 9 | + | a combined span | 1 4 4 2 4 4 3 6 9 | + + + @wip + Scenario Outline: Access cell by row and column index + Given a 3x3 table having + Then table.cell(, ).text is + + Examples: Reported cell text + | span-state | row | col | expected-text | + | only uniform cells | 1 | 1 | 5 | + | a horizontal span | 1 | 1 | 4 | + | a vertical span | 2 | 1 | 5 | + | a combined span | 2 | 1 | 4 | diff --git a/features/tbl-item-access.feature b/features/tbl-item-access.feature index 52c696535..8bfbec508 100644 --- a/features/tbl-item-access.feature +++ b/features/tbl-item-access.feature @@ -1,7 +1,7 @@ -Feature: Access table rows, columns, and cells +Feature: Access table rows and columns In order to query and modify individual table items - As an python-docx developer - I need the ability to access table rows, columns, and cells + As a developer using python-docx + I need the ability to access table rows and columns Scenario: Access table row collection Given a table having two rows @@ -22,27 +22,3 @@ Feature: Access table rows, columns, and cells Given a column collection having two columns Then I can iterate over the column collection And I can access a collection column by index - - Scenario: Access cell collection of table column - Given a table column having two cells - Then I can access the cell collection of the column - And I can get the length of the column cell collection - - Scenario: Access cell collection of table row - Given a table row having two cells - Then I can access the cell collection of the row - And I can get the length of the row cell collection - - Scenario: Access cell in column cell collection - Given a column cell collection having two cells - Then I can iterate over the column cells - And I can access a column cell by index - - Scenario: Access cell in row cell collection - Given a row cell collection having two cells - Then I can iterate over the row cells - And I can access a row cell by index - - Scenario: Access cell in table - Given a table having two rows - Then I can access a cell using its row and column indices From 725df2761c3fd4aba3f2ce98fafe5c0b19e8d245 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 20 Nov 2014 22:57:48 -0800 Subject: [PATCH 199/809] tbl: add _Cell.text getter --- docx/table.py | 13 +++++++++++-- tests/test_table.py | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docx/table.py b/docx/table.py index 544553b1e..54771993b 100644 --- a/docx/table.py +++ b/docx/table.py @@ -7,7 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals from .blkcntnr import BlockItemContainer -from .shared import lazyproperty, Parented, write_only_property +from .shared import lazyproperty, Parented class Table(Parented): @@ -141,7 +141,16 @@ def tables(self): """ return super(_Cell, self).tables - @write_only_property + @property + def text(self): + """ + The entire contents of this cell as a string of text. Assigning + a string to this property replaces all existing content with a single + paragraph containing the assigned text in a single run. + """ + return '\n'.join(p.text for p in self.paragraphs) + + @text.setter def text(self, text): """ Write-only. Set entire contents of cell to the string *text*. Any diff --git a/tests/test_table.py b/tests/test_table.py index 4ecf55d19..9c901a3d7 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -187,6 +187,11 @@ def it_provides_access_to_the_tables_it_contains(self, tables_fixture): count += 1 assert count == expected_count + def it_knows_what_text_it_contains(self, text_get_fixture): + cell, expected_text = text_get_fixture + text = cell.text + assert text == expected_text + def it_can_replace_its_content_with_a_string_of_text( self, text_set_fixture): cell, text, expected_xml = text_set_fixture @@ -247,6 +252,19 @@ def tables_fixture(self, request): cell = _Cell(element(cell_cxml), None) return cell, expected_count + @pytest.fixture(params=[ + ('w:tc', ''), + ('w:tc/w:p/w:r/w:t"foobar"', 'foobar'), + ('w:tc/(w:p/w:r/w:t"foo",w:p/w:r/w:t"bar")', 'foo\nbar'), + ('w:tc/(w:tcPr,w:p/w:r/w:t"foobar")', 'foobar'), + ('w:tc/w:p/w:r/(w:t"fo",w:tab,w:t"ob",w:br,w:t"ar",w:br)', + 'fo\tob\nar\n'), + ]) + def text_get_fixture(self, request): + tc_cxml, expected_text = request.param + cell = _Cell(element(tc_cxml), None) + return cell, expected_text + @pytest.fixture(params=[ ('w:tc/w:p', 'foobar', 'w:tc/w:p/w:r/w:t"foobar"'), From 3de03c880290e3e372a196aab4c9453d07a8b124 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 20 Nov 2014 23:38:49 -0800 Subject: [PATCH 200/809] tbl: reimplement _Row.cells Temporarily named _Row.cells_new --- docx/table.py | 27 +++++++++++++++++++++++++++ features/steps/table.py | 2 +- tests/test_table.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/docx/table.py b/docx/table.py index 54771993b..3b8b35465 100644 --- a/docx/table.py +++ b/docx/table.py @@ -67,6 +67,12 @@ def columns(self): """ return _Columns(self._tbl, self) + def row_cells(self, row_idx): + """ + Sequence of cells in the row at *row_idx* in this table. + """ + raise NotImplementedError + @lazyproperty def rows(self): """ @@ -297,6 +303,27 @@ def cells(self): """ return _RowCells(self._tr, self) + @property + def cells_new(self): + """ + Sequence of |_Cell| instances corresponding to cells in this row. + """ + return tuple(self.table.row_cells(self._index)) + + @property + def table(self): + """ + Reference to the |Table| object this row belongs to. + """ + raise NotImplementedError + + @property + def _index(self): + """ + Index of this row in its table, starting from zero. + """ + raise NotImplementedError + class _RowCells(Parented): """ diff --git a/features/steps/table.py b/features/steps/table.py index 19c08c31d..ddec02e80 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -269,7 +269,7 @@ def then_the_reported_width_of_the_cell_is_width(context, width): @then('the row cells text is {expected_text}') def then_the_row_cells_text_is_expected_text(context, expected_text): table = context.table_ - cells_text = ' '.join(c.text for row in table.rows for c in row.cells) + cells_text = ' '.join(c.text for row in table.rows for c in row.cells_new) assert cells_text == expected_text, 'got %s' % cells_text diff --git a/tests/test_table.py b/tests/test_table.py index 9c901a3d7..5ad6df1f4 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -17,6 +17,7 @@ from .oxml.unitdata.table import a_gridCol, a_tbl, a_tblGrid, a_tc, a_tr from .oxml.unitdata.text import a_p from .unitutil.cxml import element, xml +from .unitutil.mock import instance_mock, property_mock class DescribeTable(object): @@ -429,11 +430,41 @@ def columns_fixture(self): class Describe_Row(object): + def it_provides_access_to_its_cells(self, cells_fixture): + row, row_idx, expected_cells = cells_fixture + cells = row.cells_new + row.table.row_cells.assert_called_once_with(row_idx) + assert cells == expected_cells + def it_provides_access_to_the_row_cells(self): row = _Row(element('w:tr'), None) cells = row.cells assert isinstance(cells, _RowCells) + # fixtures ------------------------------------------------------- + + @pytest.fixture + def cells_fixture(self, _index_, table_prop_, table_): + row = _Row(None, None) + _index_.return_value = row_idx = 6 + expected_cells = (1, 2, 3) + table_.row_cells.return_value = list(expected_cells) + return row, row_idx, expected_cells + + # fixture components --------------------------------------------- + + @pytest.fixture + def _index_(self, request): + return property_mock(request, _Row, '_index') + + @pytest.fixture + def table_(self, request): + return instance_mock(request, Table) + + @pytest.fixture + def table_prop_(self, request, table_): + return property_mock(request, _Row, 'table', return_value=table_) + class Describe_RowCells(object): From acf76f46640a8a49e7bff73a19d10357ad9f8c2a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 00:24:18 -0800 Subject: [PATCH 201/809] tbl: add _Row.table --- docx/table.py | 12 +++++++++++- tests/test_table.py | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/docx/table.py b/docx/table.py index 3b8b35465..bd5fda53f 100644 --- a/docx/table.py +++ b/docx/table.py @@ -93,6 +93,16 @@ def style(self): def style(self, value): self._tblPr.style = value + @property + def table(self): + """ + Provide child objects with reference to the |Table| object they + belong to, without them having to know their direct parent is + a |Table| object. This is the terminus of a series of `parent._table` + calls from an arbitrary child through its ancestors. + """ + raise NotImplementedError + @property def _tblPr(self): return self._tbl.tblPr @@ -315,7 +325,7 @@ def table(self): """ Reference to the |Table| object this row belongs to. """ - raise NotImplementedError + return self._parent.table @property def _index(self): diff --git a/tests/test_table.py b/tests/test_table.py index 5ad6df1f4..c27e64773 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -441,6 +441,10 @@ def it_provides_access_to_the_row_cells(self): cells = row.cells assert isinstance(cells, _RowCells) + def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): + row, table_ = table_fixture + assert row.table is table_ + # fixtures ------------------------------------------------------- @pytest.fixture @@ -451,12 +455,22 @@ def cells_fixture(self, _index_, table_prop_, table_): table_.row_cells.return_value = list(expected_cells) return row, row_idx, expected_cells + @pytest.fixture + def table_fixture(self, parent_, table_): + row = _Row(None, parent_) + parent_.table = table_ + return row, table_ + # fixture components --------------------------------------------- @pytest.fixture def _index_(self, request): return property_mock(request, _Row, '_index') + @pytest.fixture + def parent_(self, request): + return instance_mock(request, Table) + @pytest.fixture def table_(self, request): return instance_mock(request, Table) From f0ade55524fc52c583e55bc44847fc813a2a275d Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 1 Nov 2014 01:48:58 -0700 Subject: [PATCH 202/809] tbl: add _Rows.table --- docx/table.py | 7 +++++++ tests/test_table.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/docx/table.py b/docx/table.py index bd5fda53f..af382ef9c 100644 --- a/docx/table.py +++ b/docx/table.py @@ -386,3 +386,10 @@ def __iter__(self): def __len__(self): return len(self._tbl.tr_lst) + + @property + def table(self): + """ + Reference to the |Table| object this row collection belongs to. + """ + return self._parent.table diff --git a/tests/test_table.py b/tests/test_table.py index c27e64773..f0260f5a7 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -547,6 +547,10 @@ def it_raises_on_indexed_access_out_of_range(self, rows_fixture): too_high = row_count rows[too_high] + def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): + rows, table_ = table_fixture + assert rows.table is table_ + # fixtures ------------------------------------------------------- @pytest.fixture @@ -556,6 +560,18 @@ def rows_fixture(self): rows = _Rows(tbl, None) return rows, row_count + @pytest.fixture + def table_fixture(self, table_): + rows = _Rows(None, table_) + table_.table = table_ + return rows, table_ + + # fixture components --------------------------------------------- + + @pytest.fixture + def table_(self, request): + return instance_mock(request, Table) + # fixtures ----------------------------------------------------------- From b5c4a5eec42c80efd3f73b908946b615bfc54a37 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 1 Nov 2014 01:41:46 -0700 Subject: [PATCH 203/809] tbl: add Table.table --- docx/table.py | 2 +- tests/test_table.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docx/table.py b/docx/table.py index af382ef9c..cd4c7d190 100644 --- a/docx/table.py +++ b/docx/table.py @@ -101,7 +101,7 @@ def table(self): a |Table| object. This is the terminus of a series of `parent._table` calls from an arbitrary child through its ancestors. """ - raise NotImplementedError + return self @property def _tblPr(self): diff --git a/tests/test_table.py b/tests/test_table.py index f0260f5a7..af5c2abe2 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -71,6 +71,10 @@ def it_can_change_its_autofit_setting(self, autofit_set_fixture): table.autofit = new_value assert table._tbl.xml == expected_xml + def it_knows_it_is_the_table_its_children_belong_to(self, table_fixture): + table = table_fixture + assert table.table is table + # fixtures ------------------------------------------------------- @pytest.fixture @@ -116,6 +120,11 @@ def autofit_set_fixture(self, request): expected_xml = xml(expected_tbl_cxml) return table, new_value, expected_xml + @pytest.fixture + def table_fixture(self): + table = Table(None, None) + return table + @pytest.fixture(params=[ ('w:tbl/w:tblPr', None), ('w:tbl/w:tblPr/w:tblStyle{w:val=foobar}', 'foobar'), From f9197850641f5ad6baca6a35d7fd31e88242b814 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 19:52:27 -0800 Subject: [PATCH 204/809] tbl: add _Row._index --- docx/oxml/table.py | 8 ++++++++ docx/table.py | 2 +- tests/test_table.py | 11 +++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index f2fbd540f..27704f196 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -24,6 +24,14 @@ class CT_Row(BaseOxmlElement): """ tc = ZeroOrMore('w:tc') + @property + def tr_idx(self): + """ + The index of this ```` element within its parent ```` + element. + """ + return self.getparent().tr_lst.index(self) + def _new_tc(self): return CT_Tc.new() diff --git a/docx/table.py b/docx/table.py index cd4c7d190..4be08389b 100644 --- a/docx/table.py +++ b/docx/table.py @@ -332,7 +332,7 @@ def _index(self): """ Index of this row in its table, starting from zero. """ - raise NotImplementedError + return self._tr.tr_idx class _RowCells(Parented): diff --git a/tests/test_table.py b/tests/test_table.py index af5c2abe2..383afa4d9 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -454,6 +454,10 @@ def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): row, table_ = table_fixture assert row.table is table_ + def it_knows_its_index_in_table_to_help(self, idx_fixture): + row, expected_idx = idx_fixture + assert row._index == expected_idx + # fixtures ------------------------------------------------------- @pytest.fixture @@ -464,6 +468,13 @@ def cells_fixture(self, _index_, table_prop_, table_): table_.row_cells.return_value = list(expected_cells) return row, row_idx, expected_cells + @pytest.fixture + def idx_fixture(self): + tbl = element('w:tbl/(w:tr,w:tr,w:tr)') + tr, expected_idx = tbl[1], 1 + row = _Row(tr, None) + return row, expected_idx + @pytest.fixture def table_fixture(self, parent_, table_): row = _Row(None, parent_) From ce3ecf788aa7545d0f41080a94405abc0a1b938c Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 20:30:05 -0800 Subject: [PATCH 205/809] tbl: add Table.row_cells() --- docx/table.py | 21 ++++++++++++++++++++- tests/test_table.py | 22 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/docx/table.py b/docx/table.py index 4be08389b..6bfad0180 100644 --- a/docx/table.py +++ b/docx/table.py @@ -71,7 +71,10 @@ def row_cells(self, row_idx): """ Sequence of cells in the row at *row_idx* in this table. """ - raise NotImplementedError + column_count = self._column_count + start = row_idx * column_count + end = start + column_count + return self._cells[start:end] @lazyproperty def rows(self): @@ -103,6 +106,22 @@ def table(self): """ return self + @property + def _cells(self): + """ + A sequence of |_Cell| objects, one for each cell of the layout grid. + If the table contains a span, one or more |_Cell| object references + are repeated. + """ + raise NotImplementedError + + @property + def _column_count(self): + """ + The number of grid columns in this table. + """ + raise NotImplementedError + @property def _tblPr(self): return self._tbl.tblPr diff --git a/tests/test_table.py b/tests/test_table.py index 383afa4d9..87d04bcf3 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -39,6 +39,11 @@ def it_provides_access_to_a_cell_by_row_and_col_indices(self, table): tc = tr.tc_lst[col_idx] assert tc is cell._tc + def it_provides_access_to_the_cells_in_a_row(self, row_cells_fixture): + table, row_idx, expected_cells = row_cells_fixture + row_cells = table.row_cells(row_idx) + assert row_cells == expected_cells + def it_can_add_a_row(self, add_row_fixture): table, expected_xml = add_row_fixture row = table.add_row() @@ -120,6 +125,15 @@ def autofit_set_fixture(self, request): expected_xml = xml(expected_tbl_cxml) return table, new_value, expected_xml + @pytest.fixture + def row_cells_fixture(self, _cells_, _column_count_): + table = Table(None, None) + _cells_.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] + _column_count_.return_value = 3 + row_idx = 1 + expected_cells = [3, 4, 5] + return table, row_idx, expected_cells + @pytest.fixture def table_fixture(self): table = Table(None, None) @@ -152,6 +166,14 @@ def table_style_set_fixture(self, request): # fixture components --------------------------------------------- + @pytest.fixture + def _cells_(self, request): + return property_mock(request, Table, '_cells') + + @pytest.fixture + def _column_count_(self, request): + return property_mock(request, Table, '_column_count') + @pytest.fixture def table(self): tbl = _tbl_bldr(rows=2, cols=2).element From 7fb29b155218ff6fc2cc1369f55eef48926dcfc2 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 20:44:18 -0800 Subject: [PATCH 206/809] tbl: add Table._column_count --- docx/oxml/table.py | 7 +++++++ docx/table.py | 2 +- tests/test_table.py | 12 ++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 27704f196..8f94449d6 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -53,6 +53,13 @@ def new(cls): tbl = parse_xml(cls._tbl_xml()) return tbl + @property + def col_count(self): + """ + The number of grid columns in this table. + """ + return len(self.tblGrid.gridCol_lst) + @classmethod def _tbl_xml(cls): return ( diff --git a/docx/table.py b/docx/table.py index 6bfad0180..f6eb803b5 100644 --- a/docx/table.py +++ b/docx/table.py @@ -120,7 +120,7 @@ def _column_count(self): """ The number of grid columns in this table. """ - raise NotImplementedError + return self._tbl.col_count @property def _tblPr(self): diff --git a/tests/test_table.py b/tests/test_table.py index 87d04bcf3..cb7e90eea 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -80,6 +80,11 @@ def it_knows_it_is_the_table_its_children_belong_to(self, table_fixture): table = table_fixture assert table.table is table + def it_knows_its_column_count_to_help(self, column_count_fixture): + table, expected_value = column_count_fixture + column_count = table._column_count + assert column_count == expected_value + # fixtures ------------------------------------------------------- @pytest.fixture @@ -125,6 +130,13 @@ def autofit_set_fixture(self, request): expected_xml = xml(expected_tbl_cxml) return table, new_value, expected_xml + @pytest.fixture + def column_count_fixture(self): + tbl_cxml = 'w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)' + expected_value = 3 + table = Table(element(tbl_cxml), None) + return table, expected_value + @pytest.fixture def row_cells_fixture(self, _cells_, _column_count_): table = Table(None, None) From 316b0039edfdcf9a135018fea6683559a16111cb Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 21:09:31 -0800 Subject: [PATCH 207/809] test: add snippet infrastructure --- tests/unitutil/file.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/unitutil/file.py b/tests/unitutil/file.py index 968a47d1d..8462e6c42 100644 --- a/tests/unitutil/file.py +++ b/tests/unitutil/file.py @@ -27,6 +27,33 @@ def docx_path(name): return absjoin(test_file_dir, '%s.docx' % name) +def snippet_seq(name, offset=0, count=1024): + """ + Return a tuple containing the unicode text snippets read from the snippet + file having *name*. Snippets are delimited by a blank line. If specified, + *count* snippets starting at *offset* are returned. + """ + path = os.path.join(test_file_dir, 'snippets', '%s.txt' % name) + with open(path, 'rb') as f: + text = f.read().decode('utf-8') + snippets = text.split('\n\n') + start, end = offset, offset+count + return tuple(snippets[start:end]) + + +def snippet_text(snippet_file_name): + """ + Return the unicode text read from the test snippet file having + *snippet_file_name*. + """ + snippet_file_path = os.path.join( + test_file_dir, 'snippets', '%s.txt' % snippet_file_name + ) + with open(snippet_file_path, 'rb') as f: + snippet_bytes = f.read() + return snippet_bytes.decode('utf-8') + + def test_file(name): """ Return the absolute path to test file having *name*. From 22752a9aac98e8886eee7202f385f195ac0f175e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 22:39:29 -0800 Subject: [PATCH 208/809] tbl: add Table._cells --- docx/oxml/__init__.py | 4 +- docx/oxml/simpletypes.py | 43 +++++-- docx/oxml/table.py | 103 +++++++++++++--- docx/table.py | 13 +- features/tbl-cell-access.feature | 1 - tests/test_files/snippets/tbl-cells.txt | 157 ++++++++++++++++++++++++ tests/test_table.py | 25 ++++ 7 files changed, 316 insertions(+), 30 deletions(-) create mode 100644 tests/test_files/snippets/tbl-cells.txt diff --git a/docx/oxml/__init__.py b/docx/oxml/__init__.py index c5938c7c8..b397a1b46 100644 --- a/docx/oxml/__init__.py +++ b/docx/oxml/__init__.py @@ -115,9 +115,10 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): from docx.oxml.table import ( CT_Row, CT_Tbl, CT_TblGrid, CT_TblGridCol, CT_TblLayoutType, CT_TblPr, - CT_TblWidth, CT_Tc, CT_TcPr + CT_TblWidth, CT_Tc, CT_TcPr, CT_VMerge ) 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) @@ -127,6 +128,7 @@ def OxmlElement(nsptag_str, attrs=None, nsdecls=None): 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:vMerge', CT_VMerge) from docx.oxml.text import ( CT_Br, CT_Jc, CT_P, CT_PPr, CT_R, CT_RPr, CT_Text, CT_Underline diff --git a/docx/oxml/simpletypes.py b/docx/oxml/simpletypes.py index 07b51d533..95fdcca7b 100644 --- a/docx/oxml/simpletypes.py +++ b/docx/oxml/simpletypes.py @@ -54,34 +54,45 @@ def validate_string(cls, value): ) -class BaseStringType(BaseSimpleType): +class BaseIntType(BaseSimpleType): @classmethod def convert_from_xml(cls, str_value): - return str_value + return int(str_value) @classmethod def convert_to_xml(cls, value): - return value + return str(value) @classmethod def validate(cls, value): - cls.validate_string(value) + cls.validate_int(value) -class BaseIntType(BaseSimpleType): +class BaseStringType(BaseSimpleType): @classmethod def convert_from_xml(cls, str_value): - return int(str_value) + return str_value @classmethod def convert_to_xml(cls, value): - return str(value) + return value @classmethod def validate(cls, value): - cls.validate_int(value) + cls.validate_string(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) + ) class XsdAnyUri(BaseStringType): @@ -144,6 +155,12 @@ class XsdString(BaseStringType): pass +class XsdStringEnumeration(BaseStringEnumerationType): + """ + Set of enumerated xsd:string values. + """ + + class XsdToken(BaseStringType): """ xsd:string with whitespace collapsing, e.g. multiple spaces reduced to @@ -218,6 +235,16 @@ class ST_DrawingElementId(XsdUnsignedInt): pass +class ST_Merge(XsdStringEnumeration): + """ + Valid values for attribute + """ + CONTINUE = 'continue' + RESTART = 'restart' + + _members = (CONTINUE, RESTART) + + class ST_OnOff(XsdBoolean): @classmethod diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 8f94449d6..cfc9a23be 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -10,7 +10,7 @@ from .ns import nsdecls from ..shared import Emu, Twips from .simpletypes import ( - ST_TblLayoutType, ST_TblWidth, ST_TwipsMeasure, XsdInt + ST_Merge, ST_TblLayoutType, ST_TblWidth, ST_TwipsMeasure, XsdInt ) from .xmlchemy import ( BaseOxmlElement, OneAndOnlyOne, OneOrMore, OptionalAttribute, @@ -44,6 +44,16 @@ class CT_Tbl(BaseOxmlElement): tblGrid = OneAndOnlyOne('w:tblGrid') tr = ZeroOrMore('w:tr') + def iter_tcs(self): + """ + Generate each of the `w:tc` elements in this table, left to right and + top to bottom. Each cell in the first row is generated, followed by + each cell in the second row, etc. + """ + for tr in self.tr_lst: + for tc in tr.tc_lst: + yield tc + @classmethod def new(cls): """ @@ -182,18 +192,6 @@ class CT_Tc(BaseOxmlElement): p = OneOrMore('w:p') tbl = OneOrMore('w:tbl') - def _insert_tcPr(self, tcPr): - """ - ``tcPr`` has a bunch of successors, but it comes first if it appears, - so just overriding and using insert(0, ...) rather than spelling out - successors. - """ - self.insert(0, tcPr) - return tcPr - - def _new_tbl(self): - return CT_Tbl.new() - def clear_content(self): """ Remove all content child elements, preserving the ```` @@ -208,6 +206,17 @@ def clear_content(self): new_children.append(tcPr) self[:] = new_children + @property + def grid_span(self): + """ + The integer number of columns this cell spans. Determined by + ./w:tcPr/w:gridSpan/@val, it defaults to 1. + """ + tcPr = self.tcPr + if tcPr is None: + return 1 + return tcPr.grid_span + @classmethod def new(cls): """ @@ -220,6 +229,17 @@ def new(cls): '' % nsdecls('w') ) + @property + def vMerge(self): + """ + The value of the ./w:tcPr/w:vMerge/@val attribute, or |None| if the + w:vMerge element is not present. + """ + tcPr = self.tcPr + if tcPr is None: + return None + return tcPr.vMerge_val + @property def width(self): """ @@ -236,17 +256,55 @@ def width(self, value): tcPr = self.get_or_add_tcPr() tcPr.width = value + def _insert_tcPr(self, tcPr): + """ + ``tcPr`` has a bunch of successors, but it comes first if it appears, + so just overriding and using insert(0, ...) rather than spelling out + successors. + """ + self.insert(0, tcPr) + return tcPr + + def _new_tbl(self): + return CT_Tbl.new() + class CT_TcPr(BaseOxmlElement): """ ```` element, defining table cell properties """ - tcW = ZeroOrOne('w:tcW', successors=( - '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' - )) + _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' + ) + tcW = ZeroOrOne('w:tcW', successors=_tag_seq[2:]) + gridSpan = ZeroOrOne('w:gridSpan', successors=_tag_seq[3:]) + vMerge = ZeroOrOne('w:vMerge', successors=_tag_seq[5:]) + del _tag_seq + + @property + def grid_span(self): + """ + The integer number of columns this cell spans. Determined by + ./w:gridSpan/@val, it defaults to 1. + """ + gridSpan = self.gridSpan + if gridSpan is None: + return 1 + return gridSpan.val + + @property + def vMerge_val(self): + """ + The value of the ./w:vMerge/@val attribute, or |None| if the + w:vMerge element is not present. + """ + vMerge = self.vMerge + if vMerge is None: + return None + return vMerge.val @property def width(self): @@ -263,3 +321,10 @@ def width(self): def width(self, value): tcW = self.get_or_add_tcW() tcW.width = value + + +class CT_VMerge(BaseOxmlElement): + """ + ```` element, specifying vertical merging behavior of a cell. + """ + val = OptionalAttribute('w:val', ST_Merge, default=ST_Merge.CONTINUE) diff --git a/docx/table.py b/docx/table.py index f6eb803b5..28d8d1823 100644 --- a/docx/table.py +++ b/docx/table.py @@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals from .blkcntnr import BlockItemContainer +from .oxml.simpletypes import ST_Merge from .shared import lazyproperty, Parented @@ -113,7 +114,17 @@ def _cells(self): If the table contains a span, one or more |_Cell| object references are repeated. """ - raise NotImplementedError + col_count = self._column_count + cells = [] + for tc in self._tbl.iter_tcs(): + for grid_span_idx in range(tc.grid_span): + if tc.vMerge == ST_Merge.CONTINUE: + cells.append(cells[-col_count]) + elif grid_span_idx > 0: + cells.append(cells[-1]) + else: + cells.append(_Cell(tc, self)) + return cells @property def _column_count(self): diff --git a/features/tbl-cell-access.feature b/features/tbl-cell-access.feature index 7dbd1a6b0..160387491 100644 --- a/features/tbl-cell-access.feature +++ b/features/tbl-cell-access.feature @@ -3,7 +3,6 @@ Feature: Access table cells As a developer using python-docx I need a way to access a cell from a table, row, or column - @wip Scenario Outline: Access cell sequence of a row Given a 3x3 table having Then the row cells text is diff --git a/tests/test_files/snippets/tbl-cells.txt b/tests/test_files/snippets/tbl-cells.txt new file mode 100644 index 000000000..5f1b8281b --- /dev/null +++ b/tests/test_files/snippets/tbl-cells.txt @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_table.py b/tests/test_table.py index cb7e90eea..366ce3065 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -8,6 +8,7 @@ import pytest +from docx.oxml import parse_xml from docx.shared import Inches from docx.table import ( _Cell, _Column, _ColumnCells, _Columns, _Row, _RowCells, _Rows, Table @@ -17,6 +18,7 @@ from .oxml.unitdata.table import a_gridCol, a_tbl, a_tblGrid, a_tc, a_tr from .oxml.unitdata.text import a_p from .unitutil.cxml import element, xml +from .unitutil.file import snippet_seq from .unitutil.mock import instance_mock, property_mock @@ -85,6 +87,16 @@ def it_knows_its_column_count_to_help(self, column_count_fixture): column_count = table._column_count assert column_count == expected_value + def it_provides_access_to_its_cells_to_help(self, cells_fixture): + table, cell_count, unique_count, matches = cells_fixture + cells = table._cells + assert len(cells) == cell_count + assert len(set(cells)) == unique_count + for matching_idxs in matches: + comparator_idx = matching_idxs[0] + for idx in matching_idxs[1:]: + assert cells[idx] is cells[comparator_idx] + # fixtures ------------------------------------------------------- @pytest.fixture @@ -130,6 +142,19 @@ def autofit_set_fixture(self, request): expected_xml = xml(expected_tbl_cxml) return table, new_value, expected_xml + @pytest.fixture(params=[ + (0, 9, 9, ()), + (1, 9, 8, ((0, 1),)), + (2, 9, 8, ((1, 4),)), + (3, 9, 6, ((0, 1, 3, 4),)), + (4, 9, 4, ((0, 1), (3, 6), (4, 5, 7, 8))), + ]) + def cells_fixture(self, request): + snippet_idx, cell_count, unique_count, matches = request.param + tbl_xml = snippet_seq('tbl-cells')[snippet_idx] + table = Table(parse_xml(tbl_xml), None) + return table, cell_count, unique_count, matches + @pytest.fixture def column_count_fixture(self): tbl_cxml = 'w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)' From ba3c7578014df5a4030d5d2669d9fda1897e5ed1 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 22:44:47 -0800 Subject: [PATCH 209/809] tbl: remove now-dead code * remove _RowCells and tests * make _Row.cells_new _Row.cells and remove prior version --- docx/table.py | 36 +------------------------------ features/steps/table.py | 2 +- tests/test_table.py | 47 ++--------------------------------------- 3 files changed, 4 insertions(+), 81 deletions(-) diff --git a/docx/table.py b/docx/table.py index 28d8d1823..b68787446 100644 --- a/docx/table.py +++ b/docx/table.py @@ -335,16 +335,8 @@ def __init__(self, tr, parent): super(_Row, self).__init__(parent) self._tr = tr - @lazyproperty - def cells(self): - """ - Sequence of |_Cell| instances corresponding to cells in this row. - Supports ``len()``, iteration and indexed access. - """ - return _RowCells(self._tr, self) - @property - def cells_new(self): + def cells(self): """ Sequence of |_Cell| instances corresponding to cells in this row. """ @@ -365,32 +357,6 @@ def _index(self): return self._tr.tr_idx -class _RowCells(Parented): - """ - Sequence of |_Cell| instances corresponding to the cells in a table row. - """ - def __init__(self, tr, parent): - super(_RowCells, self).__init__(parent) - self._tr = tr - - def __getitem__(self, idx): - """ - Provide indexed access, (e.g. 'cells[0]') - """ - try: - tc = self._tr.tc_lst[idx] - except IndexError: - msg = "cell index [%d] is out of range" % idx - raise IndexError(msg) - return _Cell(tc, self) - - def __iter__(self): - return (_Cell(tc, self) for tc in self._tr.tc_lst) - - def __len__(self): - return len(self._tr.tc_lst) - - class _Rows(Parented): """ Sequence of |_Row| instances corresponding to the rows in a table. diff --git a/features/steps/table.py b/features/steps/table.py index ddec02e80..19c08c31d 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -269,7 +269,7 @@ def then_the_reported_width_of_the_cell_is_width(context, width): @then('the row cells text is {expected_text}') def then_the_row_cells_text_is_expected_text(context, expected_text): table = context.table_ - cells_text = ' '.join(c.text for row in table.rows for c in row.cells_new) + cells_text = ' '.join(c.text for row in table.rows for c in row.cells) assert cells_text == expected_text, 'got %s' % cells_text diff --git a/tests/test_table.py b/tests/test_table.py index 366ce3065..86a6a702f 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -11,7 +11,7 @@ from docx.oxml import parse_xml from docx.shared import Inches from docx.table import ( - _Cell, _Column, _ColumnCells, _Columns, _Row, _RowCells, _Rows, Table + _Cell, _Column, _ColumnCells, _Columns, _Row, _Rows, Table ) from docx.text import Paragraph @@ -500,15 +500,10 @@ class Describe_Row(object): def it_provides_access_to_its_cells(self, cells_fixture): row, row_idx, expected_cells = cells_fixture - cells = row.cells_new + cells = row.cells row.table.row_cells.assert_called_once_with(row_idx) assert cells == expected_cells - def it_provides_access_to_the_row_cells(self): - row = _Row(element('w:tr'), None) - cells = row.cells - assert isinstance(cells, _RowCells) - def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): row, table_ = table_fixture assert row.table is table_ @@ -559,44 +554,6 @@ def table_prop_(self, request, table_): return property_mock(request, _Row, 'table', return_value=table_) -class Describe_RowCells(object): - - def it_knows_how_many_cells_it_contains(self, cell_count_fixture): - cells, cell_count = cell_count_fixture - assert len(cells) == cell_count - - def it_can_iterate_over_its__Cell_instances(self, cell_count_fixture): - cells, cell_count = cell_count_fixture - actual_count = 0 - for cell in cells: - assert isinstance(cell, _Cell) - actual_count += 1 - assert actual_count == cell_count - - def it_provides_indexed_access_to_cells(self, cell_count_fixture): - cells, cell_count = cell_count_fixture - for idx in range(-cell_count, cell_count): - cell = cells[idx] - assert isinstance(cell, _Cell) - - def it_raises_on_indexed_access_out_of_range(self, cell_count_fixture): - cells, cell_count = cell_count_fixture - too_low = -1 - cell_count - too_high = cell_count - with pytest.raises(IndexError): - cells[too_low] - with pytest.raises(IndexError): - cells[too_high] - - # fixtures ------------------------------------------------------- - - @pytest.fixture - def cell_count_fixture(self): - cells = _RowCells(element('w:tr/(w:tc, w:tc)'), None) - cell_count = 2 - return cells, cell_count - - class Describe_Rows(object): def it_knows_how_many_rows_it_contains(self, rows_fixture): From f6158779f263d5b8a504430aedd9a94f71cc5cb0 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Thu, 20 Nov 2014 20:57:37 -0800 Subject: [PATCH 210/809] acpt: fix paste-repeated typo in several features --- features/blk-add-paragraph.feature | 2 +- features/blk-add-table.feature | 2 +- features/cel-text.feature | 2 +- features/par-set-style.feature | 2 +- features/run-char-style.feature | 2 +- features/shp-inline-shape-access.feature | 2 +- features/tbl-add-row-or-col.feature | 2 +- features/tbl-cell-props.feature | 2 +- features/tbl-col-props.feature | 2 +- features/tbl-props.feature | 2 +- features/tbl-style.feature | 2 +- features/txt-add-break.feature | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/features/blk-add-paragraph.feature b/features/blk-add-paragraph.feature index 73e42c4c2..f873b3775 100644 --- a/features/blk-add-paragraph.feature +++ b/features/blk-add-paragraph.feature @@ -1,6 +1,6 @@ Feature: Add a paragraph of text In order to populate the text of a document - As an python-docx developer + As a developer using python-docx I need the ability to add a paragraph Scenario: Add a paragraph using low-level text API diff --git a/features/blk-add-table.feature b/features/blk-add-table.feature index 3e3696a0f..e13143e56 100644 --- a/features/blk-add-table.feature +++ b/features/blk-add-table.feature @@ -1,6 +1,6 @@ Feature: Add a table In order to fulfill a requirement for a table in a document - As an python-docx developer + As a developer using python-docx I need the ability to add a table Scenario: Access a table diff --git a/features/cel-text.feature b/features/cel-text.feature index 2bd8fb055..8373f8ae7 100644 --- a/features/cel-text.feature +++ b/features/cel-text.feature @@ -1,6 +1,6 @@ Feature: Set table cell text In order to quickly populate a table cell with regular text - As an python-docx developer working with a table + As a developer using python-docx I need the ability to set the text of a table cell Scenario: Set table cell text diff --git a/features/par-set-style.feature b/features/par-set-style.feature index ca9303b4d..9b9f0e90c 100644 --- a/features/par-set-style.feature +++ b/features/par-set-style.feature @@ -1,6 +1,6 @@ Feature: Each paragraph has a read/write style In order to use the stylesheet capability built into Word - As an python-docx developer + As a developer using python-docx I need the ability to get and set the style of a paragraph Scenario: Set the style of a paragraph diff --git a/features/run-char-style.feature b/features/run-char-style.feature index 914025658..6d108a23c 100644 --- a/features/run-char-style.feature +++ b/features/run-char-style.feature @@ -1,6 +1,6 @@ Feature: Each run has a read/write style In order to use the stylesheet capability built into Word - As an python-docx developer + As a developer using python-docx I need the ability to get and set the character style of a run diff --git a/features/shp-inline-shape-access.feature b/features/shp-inline-shape-access.feature index 5c9c0efad..c001ac526 100644 --- a/features/shp-inline-shape-access.feature +++ b/features/shp-inline-shape-access.feature @@ -1,6 +1,6 @@ Feature: Access inline shapes in document In order to query or manipulate inline shapes in a document - As an python-docx developer + As a developer using python-docx I need the ability to access the inline shapes in a document Scenario: Access inline shapes collection of document diff --git a/features/tbl-add-row-or-col.feature b/features/tbl-add-row-or-col.feature index 22946085a..74b3a9da1 100644 --- a/features/tbl-add-row-or-col.feature +++ b/features/tbl-add-row-or-col.feature @@ -1,6 +1,6 @@ Feature: Add a row or column to a table In order to extend an existing table - As an python-docx developer + As a developer using python-docx I need methods to add a row or column Scenario: Add a row to a table diff --git a/features/tbl-cell-props.feature b/features/tbl-cell-props.feature index 620a55092..32e59dfbe 100644 --- a/features/tbl-cell-props.feature +++ b/features/tbl-cell-props.feature @@ -1,6 +1,6 @@ Feature: Get and set table cell properties In order to format a table cell to my requirements - As an python-docx developer + As a developer using python-docx I need a way to get and set the properties of a table cell diff --git a/features/tbl-col-props.feature b/features/tbl-col-props.feature index 30f5aaca5..3409b6591 100644 --- a/features/tbl-col-props.feature +++ b/features/tbl-col-props.feature @@ -1,6 +1,6 @@ Feature: Get and set table column widths In order to produce properly formatted tables - As an python-docx developer + As a developer using python-docx I need a way to get and set the width of a table's columns diff --git a/features/tbl-props.feature b/features/tbl-props.feature index 613f2299c..05a018cdc 100644 --- a/features/tbl-props.feature +++ b/features/tbl-props.feature @@ -1,6 +1,6 @@ Feature: Get and set table properties In order to format a table to my requirements - As an python-docx developer + As a developer using python-docx I need a way to get and set a table's properties diff --git a/features/tbl-style.feature b/features/tbl-style.feature index 702fccd6f..b72c255ee 100644 --- a/features/tbl-style.feature +++ b/features/tbl-style.feature @@ -1,6 +1,6 @@ Feature: Query and apply a table style In order to maintain consistent formatting of tables - As an python-docx developer + As a developer using python-docx I need the ability to query and apply a table style Scenario: Access table style diff --git a/features/txt-add-break.feature b/features/txt-add-break.feature index 14a761993..db5bda819 100644 --- a/features/txt-add-break.feature +++ b/features/txt-add-break.feature @@ -1,6 +1,6 @@ Feature: Add a line, page, or column break In order to control the flow of text in a document - As an python-docx developer + As a developer using python-docx I need the ability to add a line, page, or column break Scenario: Add a line break From cbc2d60edf17f5890bf1b2fa12a152762ae5dd90 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 23:23:22 -0800 Subject: [PATCH 211/809] tbl: reimplement _Column.cells --- docx/table.py | 27 +++++++++++++++++++++++++++ features/steps/table.py | 4 +++- tests/test_table.py | 31 +++++++++++++++++++++++++++---- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/docx/table.py b/docx/table.py index b68787446..35b77f270 100644 --- a/docx/table.py +++ b/docx/table.py @@ -61,6 +61,12 @@ def cell(self, row_idx, col_idx): row = self.rows[row_idx] return row.cells[col_idx] + def column_cells(self, column_idx): + """ + Sequence of cells in the column at *column_idx* in this table. + """ + raise NotImplementedError + @lazyproperty def columns(self): """ @@ -237,6 +243,20 @@ def cells(self): """ return _ColumnCells(self._tbl, self._gridCol, self) + @property + def cells_new(self): + """ + Sequence of |_Cell| instances corresponding to cells in this column. + """ + return tuple(self.table.column_cells(self._index)) + + @property + def table(self): + """ + Reference to the |Table| object this column belongs to. + """ + raise NotImplementedError + @property def width(self): """ @@ -249,6 +269,13 @@ def width(self): def width(self, value): self._gridCol.w = value + @property + def _index(self): + """ + Index of this column in its table, starting from zero. + """ + raise NotImplementedError + class _ColumnCells(Parented): """ diff --git a/features/steps/table.py b/features/steps/table.py index 19c08c31d..82c01605f 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -216,7 +216,9 @@ def then_table_cell_row_col_text_is_text(context, row, col, expected_text): @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) + cells_text = ' '.join( + c.text for col in table.columns for c in col.cells_new + ) assert cells_text == expected_text, 'got %s' % cells_text diff --git a/tests/test_table.py b/tests/test_table.py index 86a6a702f..261859746 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -374,10 +374,11 @@ def width_set_fixture(self, request): class Describe_Column(object): - def it_provides_access_to_the_column_cells(self): - column = _Column(None, None, None) - cells = column.cells - assert isinstance(cells, _ColumnCells) + def it_provides_access_to_its_cells(self, cells_fixture): + column, column_idx, expected_cells = cells_fixture + cells = column.cells_new + column.table.column_cells.assert_called_once_with(column_idx) + assert cells == expected_cells def it_knows_its_width_in_EMU(self, width_get_fixture): column, expected_width = width_get_fixture @@ -391,6 +392,14 @@ def it_can_change_its_width(self, width_set_fixture): # fixtures ------------------------------------------------------- + @pytest.fixture + def cells_fixture(self, _index_, table_prop_, table_): + column = _Column(None, None, None) + _index_.return_value = column_idx = 4 + expected_cells = (3, 2, 1) + table_.column_cells.return_value = list(expected_cells) + return column, column_idx, expected_cells + @pytest.fixture(params=[ ('w:gridCol{w:w=4242}', 2693670), ('w:gridCol{w:w=1440}', 914400), @@ -416,6 +425,20 @@ def width_set_fixture(self, request): expected_xml = xml(expected_cxml) return column, new_value, expected_xml + # fixture components --------------------------------------------- + + @pytest.fixture + def _index_(self, request): + return property_mock(request, _Column, '_index') + + @pytest.fixture + def table_(self, request): + return instance_mock(request, Table) + + @pytest.fixture + def table_prop_(self, request, table_): + return property_mock(request, _Column, 'table', return_value=table_) + class Describe_ColumnCells(object): From dc3e8e7088661ef966f46983881691e1fe605c90 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 21 Nov 2014 23:26:47 -0800 Subject: [PATCH 212/809] tbl: add _Column.table --- docx/table.py | 2 +- tests/test_table.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docx/table.py b/docx/table.py index 35b77f270..44abbb82c 100644 --- a/docx/table.py +++ b/docx/table.py @@ -255,7 +255,7 @@ def table(self): """ Reference to the |Table| object this column belongs to. """ - raise NotImplementedError + return self._parent.table @property def width(self): diff --git a/tests/test_table.py b/tests/test_table.py index 261859746..69dbb7da0 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -380,6 +380,10 @@ def it_provides_access_to_its_cells(self, cells_fixture): column.table.column_cells.assert_called_once_with(column_idx) assert cells == expected_cells + def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): + column, table_ = table_fixture + assert column.table is table_ + def it_knows_its_width_in_EMU(self, width_get_fixture): column, expected_width = width_get_fixture assert column.width == expected_width @@ -400,6 +404,12 @@ def cells_fixture(self, _index_, table_prop_, table_): table_.column_cells.return_value = list(expected_cells) return column, column_idx, expected_cells + @pytest.fixture + def table_fixture(self, parent_, table_): + column = _Column(None, None, parent_) + parent_.table = table_ + return column, table_ + @pytest.fixture(params=[ ('w:gridCol{w:w=4242}', 2693670), ('w:gridCol{w:w=1440}', 914400), @@ -431,6 +441,10 @@ def width_set_fixture(self, request): def _index_(self, request): return property_mock(request, _Column, '_index') + @pytest.fixture + def parent_(self, request): + return instance_mock(request, Table) + @pytest.fixture def table_(self, request): return instance_mock(request, Table) From 7e22dc8ab544901868e7e7ed84f61c40d64b5aaf Mon Sep 17 00:00:00 2001 From: Apteryks Date: Fri, 21 Nov 2014 23:29:46 -0800 Subject: [PATCH 213/809] tbl: add _Columns.table --- docx/table.py | 7 +++++++ tests/test_table.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/docx/table.py b/docx/table.py index 44abbb82c..1cc56e06e 100644 --- a/docx/table.py +++ b/docx/table.py @@ -344,6 +344,13 @@ def __iter__(self): def __len__(self): return len(self._gridCol_lst) + @property + def table(self): + """ + Reference to the |Table| object this column collection belongs to. + """ + return self._parent.table + @property def _gridCol_lst(self): """ diff --git a/tests/test_table.py b/tests/test_table.py index 69dbb7da0..286fa3c79 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -523,6 +523,10 @@ def it_raises_on_indexed_access_out_of_range(self, columns_fixture): with pytest.raises(IndexError): columns[too_high] + def it_provides_access_to_the_table_it_belongs_to(self, table_fixture): + columns, table_ = table_fixture + assert columns.table is table_ + # fixtures ------------------------------------------------------- @pytest.fixture @@ -532,6 +536,18 @@ def columns_fixture(self): columns = _Columns(tbl, None) return columns, column_count + @pytest.fixture + def table_fixture(self, table_): + columns = _Columns(None, table_) + table_.table = table_ + return columns, table_ + + # fixture components --------------------------------------------- + + @pytest.fixture + def table_(self, request): + return instance_mock(request, Table) + class Describe_Row(object): From 0e3e2be51edda6e0aa96486216d4d8b6c8fccbbc Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 23:39:26 -0800 Subject: [PATCH 214/809] tbl: add _Column._index --- docx/oxml/table.py | 8 ++++++++ docx/table.py | 2 +- tests/test_table.py | 11 +++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index cfc9a23be..8224d4937 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -97,6 +97,14 @@ class CT_TblGridCol(BaseOxmlElement): """ w = OptionalAttribute('w:w', ST_TwipsMeasure) + @property + def gridCol_idx(self): + """ + The index of this ```` element within its parent + ```` element. + """ + return self.getparent().gridCol_lst.index(self) + class CT_TblLayoutType(BaseOxmlElement): """ diff --git a/docx/table.py b/docx/table.py index 1cc56e06e..78a289d8d 100644 --- a/docx/table.py +++ b/docx/table.py @@ -274,7 +274,7 @@ def _index(self): """ Index of this column in its table, starting from zero. """ - raise NotImplementedError + return self._gridCol.gridCol_idx class _ColumnCells(Parented): diff --git a/tests/test_table.py b/tests/test_table.py index 286fa3c79..47c896140 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -394,6 +394,10 @@ def it_can_change_its_width(self, width_set_fixture): assert column.width == value assert column._gridCol.xml == expected_xml + def it_knows_its_index_in_table_to_help(self, index_fixture): + column, expected_idx = index_fixture + assert column._index == expected_idx + # fixtures ------------------------------------------------------- @pytest.fixture @@ -404,6 +408,13 @@ def cells_fixture(self, _index_, table_prop_, table_): table_.column_cells.return_value = list(expected_cells) return column, column_idx, expected_cells + @pytest.fixture + def index_fixture(self): + tbl = element('w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)') + gridCol, expected_idx = tbl.tblGrid[1], 1 + column = _Column(gridCol, tbl, None) + return column, expected_idx + @pytest.fixture def table_fixture(self, parent_, table_): column = _Column(None, None, parent_) From 2fda57040311a565a04bdfe4b21b0c5764c67d77 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 23:49:07 -0800 Subject: [PATCH 215/809] tbl: add Table.column_cells() --- docx/table.py | 4 +++- features/tbl-cell-access.feature | 2 -- tests/test_table.py | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docx/table.py b/docx/table.py index 78a289d8d..2732c3851 100644 --- a/docx/table.py +++ b/docx/table.py @@ -65,7 +65,9 @@ def column_cells(self, column_idx): """ Sequence of cells in the column at *column_idx* in this table. """ - raise NotImplementedError + cells = self._cells + idxs = range(column_idx, len(cells), self._column_count) + return [cells[idx] for idx in idxs] @lazyproperty def columns(self): diff --git a/features/tbl-cell-access.feature b/features/tbl-cell-access.feature index 160387491..06f1aea31 100644 --- a/features/tbl-cell-access.feature +++ b/features/tbl-cell-access.feature @@ -15,7 +15,6 @@ Feature: Access table cells | a combined span | 1 2 3 4 4 6 4 4 9 | - @wip Scenario Outline: Access cell sequence of a column Given a 3x3 table having Then the column cells text is @@ -28,7 +27,6 @@ Feature: Access table cells | a combined span | 1 4 4 2 4 4 3 6 9 | - @wip Scenario Outline: Access cell by row and column index Given a 3x3 table having Then table.cell(, ).text is diff --git a/tests/test_table.py b/tests/test_table.py index 47c896140..b3f7a4c6d 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -41,6 +41,11 @@ def it_provides_access_to_a_cell_by_row_and_col_indices(self, table): tc = tr.tc_lst[col_idx] assert tc is cell._tc + def it_provides_access_to_the_cells_in_a_column(self, col_cells_fixture): + table, column_idx, expected_cells = col_cells_fixture + column_cells = table.column_cells(column_idx) + assert column_cells == expected_cells + def it_provides_access_to_the_cells_in_a_row(self, row_cells_fixture): table, row_idx, expected_cells = row_cells_fixture row_cells = table.row_cells(row_idx) @@ -155,6 +160,15 @@ def cells_fixture(self, request): table = Table(parse_xml(tbl_xml), None) return table, cell_count, unique_count, matches + @pytest.fixture + def col_cells_fixture(self, _cells_, _column_count_): + table = Table(None, None) + _cells_.return_value = [0, 1, 2, 3, 4, 5, 6, 7, 8] + _column_count_.return_value = 3 + column_idx = 1 + expected_cells = [1, 4, 7] + return table, column_idx, expected_cells + @pytest.fixture def column_count_fixture(self): tbl_cxml = 'w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)' From 8a9fdfcdff9af6b82debac25156ea977b372e3b9 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 21 Nov 2014 23:52:41 -0800 Subject: [PATCH 216/809] tbl: remove now-dead code --- docx/table.py | 50 +---------------------------------------- features/steps/table.py | 4 +--- tests/test_table.py | 46 ++----------------------------------- 3 files changed, 4 insertions(+), 96 deletions(-) diff --git a/docx/table.py b/docx/table.py index 2732c3851..7fff61c55 100644 --- a/docx/table.py +++ b/docx/table.py @@ -237,16 +237,8 @@ def __init__(self, gridCol, tbl, parent): self._gridCol = gridCol self._tbl = tbl - @lazyproperty - def cells(self): - """ - Sequence of |_Cell| instances corresponding to cells in this column. - Supports ``len()``, iteration and indexed access. - """ - return _ColumnCells(self._tbl, self._gridCol, self) - @property - def cells_new(self): + def cells(self): """ Sequence of |_Cell| instances corresponding to cells in this column. """ @@ -279,46 +271,6 @@ def _index(self): return self._gridCol.gridCol_idx -class _ColumnCells(Parented): - """ - Sequence of |_Cell| instances corresponding to the cells in a table - column. - """ - def __init__(self, tbl, gridCol, parent): - super(_ColumnCells, self).__init__(parent) - self._tbl = tbl - self._gridCol = gridCol - - def __getitem__(self, idx): - """ - Provide indexed access, (e.g. 'cells[0]') - """ - try: - tr = self._tr_lst[idx] - except IndexError: - msg = "cell index [%d] is out of range" % idx - raise IndexError(msg) - tc = tr.tc_lst[self._col_idx] - return _Cell(tc, self) - - def __iter__(self): - for tr in self._tr_lst: - tc = tr.tc_lst[self._col_idx] - yield _Cell(tc, self) - - def __len__(self): - return len(self._tr_lst) - - @property - def _col_idx(self): - gridCol_lst = self._tbl.tblGrid.gridCol_lst - return gridCol_lst.index(self._gridCol) - - @property - def _tr_lst(self): - return self._tbl.tr_lst - - class _Columns(Parented): """ Sequence of |_Column| instances corresponding to the columns in a table. diff --git a/features/steps/table.py b/features/steps/table.py index 82c01605f..19c08c31d 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -216,9 +216,7 @@ def then_table_cell_row_col_text_is_text(context, row, col, expected_text): @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_new - ) + cells_text = ' '.join(c.text for col in table.columns for c in col.cells) assert cells_text == expected_text, 'got %s' % cells_text diff --git a/tests/test_table.py b/tests/test_table.py index b3f7a4c6d..fce71c437 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -10,9 +10,7 @@ from docx.oxml import parse_xml from docx.shared import Inches -from docx.table import ( - _Cell, _Column, _ColumnCells, _Columns, _Row, _Rows, Table -) +from docx.table import _Cell, _Column, _Columns, _Row, _Rows, Table from docx.text import Paragraph from .oxml.unitdata.table import a_gridCol, a_tbl, a_tblGrid, a_tc, a_tr @@ -390,7 +388,7 @@ class Describe_Column(object): def it_provides_access_to_its_cells(self, cells_fixture): column, column_idx, expected_cells = cells_fixture - cells = column.cells_new + cells = column.cells column.table.column_cells.assert_called_once_with(column_idx) assert cells == expected_cells @@ -479,46 +477,6 @@ def table_prop_(self, request, table_): return property_mock(request, _Column, 'table', return_value=table_) -class Describe_ColumnCells(object): - - def it_knows_how_many_cells_it_contains(self, cells_fixture): - cells, cell_count = cells_fixture - assert len(cells) == cell_count - - def it_can_iterate_over_its__Cell_instances(self, cells_fixture): - cells, cell_count = cells_fixture - actual_count = 0 - for cell in cells: - assert isinstance(cell, _Cell) - actual_count += 1 - assert actual_count == cell_count - - def it_provides_indexed_access_to_cells(self, cells_fixture): - cells, cell_count = cells_fixture - for idx in range(-cell_count, cell_count): - cell = cells[idx] - assert isinstance(cell, _Cell) - - def it_raises_on_indexed_access_out_of_range(self, cells_fixture): - cells, cell_count = cells_fixture - too_low = -1 - cell_count - too_high = cell_count - with pytest.raises(IndexError): - cells[too_low] - with pytest.raises(IndexError): - cells[too_high] - - # fixtures ------------------------------------------------------- - - @pytest.fixture - def cells_fixture(self): - cell_count = 2 - tbl = _tbl_bldr(rows=cell_count, cols=1).element - gridCol = tbl.tblGrid.gridCol_lst[0] - cells = _ColumnCells(tbl, gridCol, None) - return cells, cell_count - - class Describe_Columns(object): def it_knows_how_many_columns_it_contains(self, columns_fixture): From a8a49e1c5e0ee9f295d9f19b261b4ff5184f5214 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 2 Nov 2014 00:46:46 -0700 Subject: [PATCH 217/809] tbl: refactor Table.cell() --- docx/table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docx/table.py b/docx/table.py index 7fff61c55..5a450fb49 100644 --- a/docx/table.py +++ b/docx/table.py @@ -58,8 +58,8 @@ def cell(self, row_idx, col_idx): Return |_Cell| instance correponding to table cell at *row_idx*, *col_idx* intersection, where (0, 0) is the top, left-most cell. """ - row = self.rows[row_idx] - return row.cells[col_idx] + cell_idx = col_idx + (row_idx * self._column_count) + return self._cells[cell_idx] def column_cells(self, column_idx): """ From 3d1534a112ce397859af1330dcfe4fee9f5cde75 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 23 Nov 2014 21:03:44 -0800 Subject: [PATCH 218/809] acpt: add scenarios for _Cell.merge() --- features/steps/table.py | 30 +++++++++++++++-- features/tbl-merge-cells.feature | 57 ++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 features/tbl-merge-cells.feature diff --git a/features/steps/table.py b/features/steps/table.py index 19c08c31d..243f22d7b 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -4,7 +4,9 @@ Step implementations for table-related features """ -from __future__ import absolute_import, print_function, unicode_literals +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) from behave import given, then, when @@ -127,6 +129,17 @@ def when_apply_style_to_table(context): table.style = 'LightShading-Accent1' +@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}') def when_I_set_the_cell_width_to_width(context, width): new_value = {'1 inch': Inches(1)}[width] @@ -266,8 +279,9 @@ def then_the_reported_width_of_the_cell_is_width(context, width): ) -@then('the row cells text is {expected_text}') -def then_the_row_cells_text_is_expected_text(context, expected_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') 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 @@ -292,3 +306,13 @@ 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') +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 diff --git a/features/tbl-merge-cells.feature b/features/tbl-merge-cells.feature new file mode 100644 index 000000000..3249f81e7 --- /dev/null +++ b/features/tbl-merge-cells.feature @@ -0,0 +1,57 @@ +Feature: Merge table cells + In order to form a table cell spanning multiple rows and/or columns + As a developer using python-docx + I need a way to merge a range of cells + + @wip + Scenario Outline: Merge cells + Given a 3x3 table having only uniform cells + When I merge from cell to cell + Then the row cells text is + + Examples: Reported row cell contents + | origin | other | expected-text | + | 1 | 2 | 1\2 1\2 3 4 5 6 7 8 9 | + | 2 | 5 | 1 2\5 3 4 2\5 6 7 8 9 | + | 5 | 9 | 1 2 3 4 5\6\8\9 5\6\8\9 7 5\6\8\9 5\6\8\9 | + + + @wip + Scenario Outline: Merge horizontal span with other cell + Given a 3x3 table having a horizontal span + When I merge from cell to cell + Then the row cells text is + + Examples: Reported row cell contents + | origin | other | expected-text | + | 4 | 8 | 1 2 3 4\7\8 4\7\8 6 4\7\8 4\7\8 9 | + | 4 | 6 | 1 2 3 4\6 4\6 4\6 7 8 9 | + | 2 | 4 | 1\2\4 1\2\4 3 1\2\4 1\2\4 6 7 8 9 | + + + @wip + Scenario Outline: Merge vertical span with other cell + Given a 3x3 table having a vertical span + When I merge from cell to cell + Then the row cells text is + + Examples: Reported row cell contents + | origin | other | expected-text | + | 5 | 9 | 1 2 3 4 5\6\9 5\6\9 7 5\6\9 5\6\9 | + | 2 | 5 | 1 2\5 3 4 2\5 6 7 2\5 9 | + | 7 | 5 | 1 2 3 4\5\7 4\5\7 6 4\5\7 4\5\7 9 | + + + @wip + Scenario Outline: Horizontal span adds cell widths + Given a 3x3 table having + When I merge from cell to cell + Then the width of cell is inches + + Examples: Reported row cell contents + | span-state | origin | other | merged | width | + | only uniform cells | 1 | 2 | 1 | 2.0 | + | only uniform cells | 1 | 5 | 1 | 2.0 | + | a horizontal span | 4 | 6 | 4 | 3.0 | + | a vertical span | 5 | 2 | 2 | 1.0 | + | a vertical span | 5 | 7 | 5 | 2.0 | From b347549a3216bc354d2ffbd226c3e3d2fa8f5ec5 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sun, 23 Nov 2014 22:00:42 -0800 Subject: [PATCH 219/809] tbl: reorder methods in Describe_Cell No code changes, just changing ordering of test methods. --- tests/test_table.py | 50 ++++++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/tests/test_table.py b/tests/test_table.py index fce71c437..e84c62e11 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -232,17 +232,26 @@ def table(self): class Describe_Cell(object): - def it_can_add_a_paragraph(self, add_paragraph_fixture): - cell, expected_xml = add_paragraph_fixture - p = cell.add_paragraph() + def it_knows_what_text_it_contains(self, text_get_fixture): + cell, expected_text = text_get_fixture + text = cell.text + assert text == expected_text + + def it_can_replace_its_content_with_a_string_of_text( + self, text_set_fixture): + cell, text, expected_xml = text_set_fixture + cell.text = text assert cell._tc.xml == expected_xml - assert isinstance(p, Paragraph) - def it_can_add_a_table(self, add_table_fixture): - cell, expected_xml = add_table_fixture - table = cell.add_table(rows=0, cols=0) + def it_knows_its_width_in_EMU(self, width_get_fixture): + cell, expected_width = width_get_fixture + assert cell.width == expected_width + + def it_can_change_its_width(self, width_set_fixture): + cell, value, expected_xml = width_set_fixture + cell.width = value + assert cell.width == value assert cell._tc.xml == expected_xml - assert isinstance(table, Table) def it_provides_access_to_the_paragraphs_it_contains( self, paragraphs_fixture): @@ -268,26 +277,17 @@ def it_provides_access_to_the_tables_it_contains(self, tables_fixture): count += 1 assert count == expected_count - def it_knows_what_text_it_contains(self, text_get_fixture): - cell, expected_text = text_get_fixture - text = cell.text - assert text == expected_text - - def it_can_replace_its_content_with_a_string_of_text( - self, text_set_fixture): - cell, text, expected_xml = text_set_fixture - cell.text = text + def it_can_add_a_paragraph(self, add_paragraph_fixture): + cell, expected_xml = add_paragraph_fixture + p = cell.add_paragraph() assert cell._tc.xml == expected_xml + assert isinstance(p, Paragraph) - def it_knows_its_width_in_EMU(self, width_get_fixture): - cell, expected_width = width_get_fixture - assert cell.width == expected_width - - def it_can_change_its_width(self, width_set_fixture): - cell, value, expected_xml = width_set_fixture - cell.width = value - assert cell.width == value + def it_can_add_a_table(self, add_table_fixture): + cell, expected_xml = add_table_fixture + table = cell.add_table(rows=0, cols=0) assert cell._tc.xml == expected_xml + assert isinstance(table, Table) # fixtures ------------------------------------------------------- From 3a447d37d97b016bb7741f7a6d085bcd966d834d Mon Sep 17 00:00:00 2001 From: Apteryks Date: Sun, 23 Nov 2014 22:52:51 -0800 Subject: [PATCH 220/809] tbl: add _Cell.merge() --- docx/oxml/table.py | 8 ++++++++ docx/table.py | 11 +++++++++++ tests/test_table.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 8224d4937..d697cf243 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -225,6 +225,14 @@ def grid_span(self): return 1 return tcPr.grid_span + def merge(self, other_tc): + """ + Return the top-left ```` element of the span formed by merging + the rectangular region defined by using this tc element and + *other_tc* as diagonal corners. + """ + raise NotImplementedError + @classmethod def new(cls): """ diff --git a/docx/table.py b/docx/table.py index 5a450fb49..533f2b47b 100644 --- a/docx/table.py +++ b/docx/table.py @@ -179,6 +179,17 @@ def add_table(self, rows, cols): self.add_paragraph() return new_table + def merge(self, other_cell): + """ + Return a merged cell created by spanning the rectangular region + demarcated by using the extents of this cell and *other_cell* as + diagonal corners. Raises |InvalidSpanError| if the cells do not + define a rectangular region. + """ + tc, tc_2 = self._tc, other_cell._tc + merged_tc = tc.merge(tc_2) + return _Cell(merged_tc, self._parent) + @property def paragraphs(self): """ diff --git a/tests/test_table.py b/tests/test_table.py index e84c62e11..c4e626402 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -9,6 +9,7 @@ import pytest from docx.oxml import parse_xml +from docx.oxml.table import CT_Tc from docx.shared import Inches from docx.table import _Cell, _Column, _Columns, _Row, _Rows, Table from docx.text import Paragraph @@ -289,6 +290,14 @@ def it_can_add_a_table(self, add_table_fixture): assert cell._tc.xml == expected_xml assert isinstance(table, Table) + def it_can_merge_itself_with_other_cells(self, merge_fixture): + cell, other_cell, merged_tc_ = merge_fixture + merged_cell = cell.merge(other_cell) + cell._tc.merge.assert_called_once_with(other_cell._tc) + assert isinstance(merged_cell, _Cell) + assert merged_cell._tc is merged_tc_ + assert merged_cell._parent is cell._parent + # fixtures ------------------------------------------------------- @pytest.fixture(params=[ @@ -317,6 +326,12 @@ def add_table_fixture(self, request): expected_xml = xml(after_tc_cxml) return cell, expected_xml + @pytest.fixture + def merge_fixture(self, tc_, tc_2_, parent_, merged_tc_): + cell, other_cell = _Cell(tc_, parent_), _Cell(tc_2_, parent_) + tc_.merge.return_value = merged_tc_ + return cell, other_cell, merged_tc_ + @pytest.fixture def paragraphs_fixture(self): return _Cell(element('w:tc/(w:p, w:p)'), None) @@ -383,6 +398,24 @@ def width_set_fixture(self, request): expected_xml = xml(expected_cxml) return cell, new_value, expected_xml + # fixture components --------------------------------------------- + + @pytest.fixture + def merged_tc_(self, request): + return instance_mock(request, CT_Tc) + + @pytest.fixture + def parent_(self, request): + return instance_mock(request, Table) + + @pytest.fixture + def tc_(self, request): + return instance_mock(request, CT_Tc) + + @pytest.fixture + def tc_2_(self, request): + return instance_mock(request, CT_Tc) + class Describe_Column(object): From 538ed191cece9699cf49f009ff5cf2b7a1e2ca12 Mon Sep 17 00:00:00 2001 From: Apteryks Date: Mon, 24 Nov 2014 01:14:36 -0800 Subject: [PATCH 221/809] tbl: add CT_Tc.merge() --- docx/oxml/table.py | 39 +++++++++++++++++++++++-- tests/oxml/test_table.py | 61 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/oxml/test_table.py diff --git a/docx/oxml/table.py b/docx/oxml/table.py index d697cf243..72258de16 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -24,6 +24,13 @@ class CT_Row(BaseOxmlElement): """ tc = ZeroOrMore('w:tc') + def tc_at_grid_col(self, idx): + """ + The ```` element appearing at grid column *idx*. Raises + |ValueError| if no ``w:tc`` element begins at that grid column. + """ + raise NotImplementedError + @property def tr_idx(self): """ @@ -227,11 +234,14 @@ def grid_span(self): def merge(self, other_tc): """ - Return the top-left ```` element of the span formed by merging - the rectangular region defined by using this tc element and + Return the top-left ```` element of a new span formed by + merging the rectangular region defined by using this tc element and *other_tc* as diagonal corners. """ - raise NotImplementedError + top, left, height, width = self._span_dimensions(other_tc) + top_tc = self._tbl.tr_lst[top].tc_at_grid_col(left) + top_tc._grow_to(width, height) + return top_tc @classmethod def new(cls): @@ -272,6 +282,14 @@ def width(self, value): tcPr = self.get_or_add_tcPr() tcPr.width = value + def _grow_to(self, width, height, top_tc=None): + """ + Grow this cell to *width* grid columns and *height* rows by expanding + horizontal spans and creating continuation cells to form vertical + spans. + """ + raise NotImplementedError + def _insert_tcPr(self, tcPr): """ ``tcPr`` has a bunch of successors, but it comes first if it appears, @@ -284,6 +302,21 @@ def _insert_tcPr(self, tcPr): def _new_tbl(self): return CT_Tbl.new() + def _span_dimensions(self, other_tc): + """ + Return a (top, left, height, width) 4-tuple specifying the extents of + the merged cell formed by using this tc and *other_tc* as opposite + corner extents. + """ + raise NotImplementedError + + @property + def _tbl(self): + """ + The tbl element this tc element appears in. + """ + raise NotImplementedError + class CT_TcPr(BaseOxmlElement): """ diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py new file mode 100644 index 000000000..135c2efeb --- /dev/null +++ b/tests/oxml/test_table.py @@ -0,0 +1,61 @@ +# encoding: utf-8 + +""" +Test suite for the docx.oxml.text module. +""" + +from __future__ import ( + absolute_import, division, print_function, unicode_literals +) + +import pytest + +from docx.oxml.table import CT_Row, CT_Tc + +from ..unitutil.cxml import element +from ..unitutil.mock import instance_mock, method_mock, property_mock + + +class DescribeCT_Tc(object): + + def it_can_merge_to_another_tc(self, merge_fixture): + tc, other_tc, top_tr_, top_tc_, left, height, width = merge_fixture + merged_tc = tc.merge(other_tc) + tc._span_dimensions.assert_called_once_with(other_tc) + top_tr_.tc_at_grid_col.assert_called_once_with(left) + top_tc_._grow_to.assert_called_once_with(width, height) + assert merged_tc is top_tc_ + + # fixtures ------------------------------------------------------- + + @pytest.fixture + def merge_fixture( + self, tr_, _span_dimensions_, _tbl_, _grow_to_, top_tc_): + tc, other_tc = element('w:tc'), element('w:tc') + top, left, height, width = 0, 1, 2, 3 + _span_dimensions_.return_value = top, left, height, width + _tbl_.return_value.tr_lst = [tr_] + tr_.tc_at_grid_col.return_value = top_tc_ + return tc, other_tc, tr_, top_tc_, left, height, width + + # fixture components --------------------------------------------- + + @pytest.fixture + def _grow_to_(self, request): + return method_mock(request, CT_Tc, '_grow_to') + + @pytest.fixture + def _span_dimensions_(self, request): + return method_mock(request, CT_Tc, '_span_dimensions') + + @pytest.fixture + def _tbl_(self, request): + return property_mock(request, CT_Tc, '_tbl') + + @pytest.fixture + def top_tc_(self, request): + return instance_mock(request, CT_Tc) + + @pytest.fixture + def tr_(self, request): + return instance_mock(request, CT_Row) From 64ef552f190eb5c6eb887ba3282e699747127c2a Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 24 Nov 2014 16:05:24 -0800 Subject: [PATCH 222/809] tbl: add CT_Tc.top, .left, .bottom, and .right --- docx/oxml/table.py | 117 ++++++++++++++++++++++++++++++++++++++- tests/oxml/test_table.py | 52 +++++++++++++++++ 2 files changed, 167 insertions(+), 2 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 72258de16..ae96c160a 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -29,7 +29,14 @@ def tc_at_grid_col(self, idx): The ```` element appearing at grid column *idx*. Raises |ValueError| if no ``w:tc`` element begins at that grid column. """ - raise NotImplementedError + grid_col = 0 + for tc in self.tc_lst: + if grid_col == 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') @property def tr_idx(self): @@ -207,6 +214,20 @@ class CT_Tc(BaseOxmlElement): p = OneOrMore('w:p') tbl = OneOrMore('w:tbl') + @property + def bottom(self): + """ + The row index that marks the bottom extent of the vertical span of + this cell. This is one greater than the index of the bottom-most row + of the span, similar to how a slice of the cell's rows would be + specified. + """ + if self.vMerge is not None: + tc_below = self._tc_below + if tc_below is not None and tc_below.vMerge == ST_Merge.CONTINUE: + return tc_below.bottom + return self._tr_idx + 1 + def clear_content(self): """ Remove all content child elements, preserving the ```` @@ -232,6 +253,13 @@ def grid_span(self): return 1 return tcPr.grid_span + @property + def left(self): + """ + The grid column index at which this ```` element appears. + """ + return self._grid_col + def merge(self, other_tc): """ Return the top-left ```` element of a new span formed by @@ -255,6 +283,25 @@ def new(cls): '' % nsdecls('w') ) + @property + def right(self): + """ + The grid column index that marks the right-side extent of the + horizontal span of this cell. This is one greater than the index of + the right-most column of the span, similar to how a slice of the + cell's columns would be specified. + """ + return self._grid_col + self.grid_span + + @property + def top(self): + """ + The top-most row index in the vertical span of this cell. + """ + if self.vMerge is None or self.vMerge == ST_Merge.RESTART: + return self._tr_idx + return self._tc_above.top + @property def vMerge(self): """ @@ -282,6 +329,16 @@ def width(self, value): tcPr = self.get_or_add_tcPr() tcPr.width = value + @property + def _grid_col(self): + """ + The grid column at which this cell begins. + """ + tr = self._tr + idx = tr.tc_lst.index(self) + preceding_tcs = tr.tc_lst[:idx] + return sum(tc.grid_span for tc in preceding_tcs) + def _grow_to(self, width, height, top_tc=None): """ Grow this cell to *width* grid columns and *height* rows by expanding @@ -315,7 +372,63 @@ def _tbl(self): """ The tbl element this tc element appears in. """ - raise NotImplementedError + return self.xpath('./ancestor::w:tbl')[0] + + @property + def _tc_above(self): + """ + The `w:tc` element immediately above this one in its grid column. + """ + return self._tr_above.tc_at_grid_col(self._grid_col) + + @property + def _tc_below(self): + """ + The tc element immediately below this one in its grid column. + """ + tr_below = self._tr_below + if tr_below is None: + return None + return tr_below.tc_at_grid_col(self._grid_col) + + @property + def _tr(self): + """ + The tr element this tc element appears in. + """ + return self.xpath('./ancestor::w:tr')[0] + + @property + def _tr_above(self): + """ + The tr element prior in sequence to the tr this cell appears in. + Raises |ValueError| if called on a cell in the top-most row. + """ + tr_lst = self._tbl.tr_lst + tr_idx = tr_lst.index(self._tr) + if tr_idx == 0: + raise ValueError('no tr above topmost tr') + return tr_lst[tr_idx-1] + + @property + def _tr_below(self): + """ + The tr element next in sequence after the tr this cell appears in, or + |None| if this cell appears in the last row. + """ + tr_lst = self._tbl.tr_lst + tr_idx = tr_lst.index(self._tr) + try: + return tr_lst[tr_idx+1] + except IndexError: + return None + + @property + def _tr_idx(self): + """ + The row index of the tr element this tc element appears in. + """ + return self._tbl.tr_lst.index(self._tr) class CT_TcPr(BaseOxmlElement): diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 135c2efeb..39bce8632 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -10,12 +10,31 @@ import pytest +from docx.oxml import parse_xml from docx.oxml.table import CT_Row, CT_Tc from ..unitutil.cxml import element +from ..unitutil.file import snippet_seq from ..unitutil.mock import instance_mock, method_mock, property_mock +class DescribeCT_Row(object): + + def it_raises_on_tc_at_grid_col(self, tc_raise_fixture): + tr, idx = tc_raise_fixture + with pytest.raises(ValueError): + tr.tc_at_grid_col(idx) + + # fixtures ------------------------------------------------------- + + @pytest.fixture(params=[(0, 0, 3), (1, 0, 1)]) + def tc_raise_fixture(self, request): + snippet_idx, row_idx, col_idx = request.param + tbl = parse_xml(snippet_seq('tbl-cells')[snippet_idx]) + tr = tbl.tr_lst[row_idx] + return tr, col_idx + + class DescribeCT_Tc(object): def it_can_merge_to_another_tc(self, merge_fixture): @@ -26,8 +45,34 @@ def it_can_merge_to_another_tc(self, merge_fixture): top_tc_._grow_to.assert_called_once_with(width, height) assert merged_tc is top_tc_ + def it_knows_its_extents_to_help(self, extents_fixture): + tc, attr_name, expected_value = extents_fixture + extent = getattr(tc, attr_name) + assert extent == expected_value + + def it_raises_on_tr_above(self, tr_above_raise_fixture): + tc = tr_above_raise_fixture + with pytest.raises(ValueError): + tc._tr_above + # fixtures ------------------------------------------------------- + @pytest.fixture(params=[ + (0, 0, 0, 'top', 0), (2, 0, 1, 'top', 0), + (2, 1, 1, 'top', 0), (4, 2, 1, 'top', 1), + (0, 0, 0, 'left', 0), (1, 0, 1, 'left', 2), + (3, 1, 0, 'left', 0), (3, 1, 1, 'left', 2), + (0, 0, 0, 'bottom', 1), (1, 0, 0, 'bottom', 1), + (2, 0, 1, 'bottom', 2), (4, 1, 1, 'bottom', 3), + (0, 0, 0, 'right', 1), (1, 0, 0, 'right', 2), + (0, 0, 0, 'right', 1), (4, 2, 1, 'right', 3), + ]) + def extents_fixture(self, request): + snippet_idx, row, col, attr_name, expected_value = request.param + tbl = parse_xml(snippet_seq('tbl-cells')[snippet_idx]) + tc = tbl.tr_lst[row].tc_lst[col] + return tc, attr_name, expected_value + @pytest.fixture def merge_fixture( self, tr_, _span_dimensions_, _tbl_, _grow_to_, top_tc_): @@ -38,6 +83,13 @@ def merge_fixture( tr_.tc_at_grid_col.return_value = top_tc_ return tc, other_tc, tr_, top_tc_, left, height, width + @pytest.fixture(params=[(0, 0, 0), (4, 0, 0)]) + def tr_above_raise_fixture(self, request): + snippet_idx, row_idx, col_idx = request.param + tbl = parse_xml(snippet_seq('tbl-cells')[snippet_idx]) + tc = tbl.tr_lst[row_idx].tc_lst[col_idx] + return tc + # fixture components --------------------------------------------- @pytest.fixture From 1f52c20830979a8e15f09fa43e595ac756a37cb0 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 24 Nov 2014 18:51:48 -0800 Subject: [PATCH 223/809] tbl: add CT_Tc._span_dimensions() --- docx/exceptions.py | 7 +++ docx/oxml/table.py | 26 ++++++++- tests/oxml/test_table.py | 56 ++++++++++++++++++- tests/test_files/snippets/tbl-cells.txt | 72 +++++++++++++++++++++++-- 4 files changed, 154 insertions(+), 7 deletions(-) diff --git a/docx/exceptions.py b/docx/exceptions.py index 00215615b..7a8b99c81 100644 --- a/docx/exceptions.py +++ b/docx/exceptions.py @@ -13,6 +13,13 @@ class PythonDocxError(Exception): """ +class InvalidSpanError(PythonDocxError): + """ + Raised when an invalid merge region is specified in a request to merge + table cells. + """ + + class InvalidXmlError(PythonDocxError): """ Raised when invalid XML is encountered, such as on attempt to access a diff --git a/docx/oxml/table.py b/docx/oxml/table.py index ae96c160a..d92ed89d6 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -7,6 +7,7 @@ from __future__ import absolute_import, print_function, unicode_literals from . import parse_xml +from ..exceptions import InvalidSpanError from .ns import nsdecls from ..shared import Emu, Twips from .simpletypes import ( @@ -365,7 +366,30 @@ def _span_dimensions(self, other_tc): the merged cell formed by using this tc and *other_tc* as opposite corner extents. """ - raise NotImplementedError + def raise_on_inverted_L(a, b): + if a.top == b.top and a.bottom != b.bottom: + raise InvalidSpanError('requested span not rectangular') + if a.left == b.left and a.right != b.right: + 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') + + 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_on_inverted_L(self, other_tc) + raise_on_tee_shaped(self, other_tc) + + top = min(self.top, other_tc.top) + left = min(self.left, other_tc.left) + bottom = max(self.bottom, other_tc.bottom) + right = max(self.right, other_tc.right) + + return top, left, bottom - top, right - left @property def _tbl(self): diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 39bce8632..487a54e1c 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -10,6 +10,7 @@ import pytest +from docx.exceptions import InvalidSpanError from docx.oxml import parse_xml from docx.oxml.table import CT_Row, CT_Tc @@ -50,6 +51,16 @@ def it_knows_its_extents_to_help(self, extents_fixture): extent = getattr(tc, attr_name) assert extent == expected_value + def it_calculates_the_dimensions_of_a_span_to_help(self, span_fixture): + tc, other_tc, expected_dimensions = span_fixture + dimensions = tc._span_dimensions(other_tc) + assert dimensions == expected_dimensions + + def it_raises_on_invalid_span(self, span_raise_fixture): + tc, other_tc = span_raise_fixture + with pytest.raises(InvalidSpanError): + tc._span_dimensions(other_tc) + def it_raises_on_tr_above(self, tr_above_raise_fixture): tc = tr_above_raise_fixture with pytest.raises(ValueError): @@ -69,7 +80,7 @@ def it_raises_on_tr_above(self, tr_above_raise_fixture): ]) def extents_fixture(self, request): snippet_idx, row, col, attr_name, expected_value = request.param - tbl = parse_xml(snippet_seq('tbl-cells')[snippet_idx]) + tbl = self._snippet_tbl(snippet_idx) tc = tbl.tr_lst[row].tc_lst[col] return tc, attr_name, expected_value @@ -83,6 +94,42 @@ def merge_fixture( tr_.tc_at_grid_col.return_value = top_tc_ return tc, other_tc, tr_, top_tc_, left, height, width + @pytest.fixture(params=[ + (0, 0, 0, 0, 1, (0, 0, 1, 2)), + (0, 0, 1, 2, 1, (0, 1, 3, 1)), + (0, 2, 2, 1, 1, (1, 1, 2, 2)), + (0, 1, 2, 1, 0, (1, 0, 1, 3)), + (1, 0, 0, 1, 1, (0, 0, 2, 2)), + (1, 0, 1, 0, 0, (0, 0, 1, 3)), + (2, 0, 1, 2, 1, (0, 1, 3, 1)), + (2, 0, 1, 1, 0, (0, 0, 2, 2)), + (2, 1, 2, 0, 1, (0, 1, 2, 2)), + (4, 0, 1, 0, 0, (0, 0, 1, 3)), + ]) + def span_fixture(self, request): + snippet_idx, row, col, row_2, col_2, expected_value = request.param + tbl = self._snippet_tbl(snippet_idx) + tc = tbl.tr_lst[row].tc_lst[col] + tc_2 = tbl.tr_lst[row_2].tc_lst[col_2] + return tc, tc_2, expected_value + + @pytest.fixture(params=[ + (1, 0, 0, 1, 0), # inverted-L horz + (1, 1, 0, 0, 0), # same in opposite order + (2, 0, 2, 0, 1), # inverted-L vert + (5, 0, 1, 1, 0), # tee-shape horz bar + (5, 1, 0, 2, 1), # same, opposite side + (6, 1, 0, 0, 1), # tee-shape vert bar + (6, 0, 1, 1, 2), # same, opposite side + ]) + def span_raise_fixture(self, request): + snippet_idx, row, col, row_2, col_2 = request.param + tbl = self._snippet_tbl(snippet_idx) + tc = tbl.tr_lst[row].tc_lst[col] + tc_2 = tbl.tr_lst[row_2].tc_lst[col_2] + print(tc.top, tc_2.top, tc.bottom, tc_2.bottom) + return tc, tc_2 + @pytest.fixture(params=[(0, 0, 0), (4, 0, 0)]) def tr_above_raise_fixture(self, request): snippet_idx, row_idx, col_idx = request.param @@ -100,6 +147,13 @@ def _grow_to_(self, request): def _span_dimensions_(self, request): return method_mock(request, CT_Tc, '_span_dimensions') + def _snippet_tbl(self, idx): + """ + Return a element for snippet at *idx* in 'tbl-cells' snippet + file. + """ + return parse_xml(snippet_seq('tbl-cells')[idx]) + @pytest.fixture def _tbl_(self, request): return property_mock(request, CT_Tc, '_tbl') diff --git a/tests/test_files/snippets/tbl-cells.txt b/tests/test_files/snippets/tbl-cells.txt index 5f1b8281b..9f2176a05 100644 --- a/tests/test_files/snippets/tbl-cells.txt +++ b/tests/test_files/snippets/tbl-cells.txt @@ -1,4 +1,4 @@ - + @@ -22,7 +22,7 @@ - + @@ -49,7 +49,7 @@ - + @@ -81,7 +81,7 @@ - + @@ -113,7 +113,7 @@ - + @@ -155,3 +155,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 954301dcadba39180df5832b6def288b1238eea5 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 24 Nov 2014 21:46:16 -0800 Subject: [PATCH 224/809] tbl: Add CT_Tc._grow_to() --- docx/oxml/table.py | 25 +++++++- tests/oxml/test_table.py | 33 +++++++++- tests/test_files/snippets/tbl-cells.txt | 84 ++++++++++++++++++++++--- 3 files changed, 133 insertions(+), 9 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index d92ed89d6..74a7f8e10 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -346,7 +346,17 @@ def _grow_to(self, width, height, top_tc=None): horizontal spans and creating continuation cells to form vertical spans. """ - raise NotImplementedError + def vMerge_val(top_tc): + if top_tc is not self: + return ST_Merge.CONTINUE + if height == 1: + return None + return ST_Merge.RESTART + + 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) def _insert_tcPr(self, tcPr): """ @@ -391,6 +401,19 @@ def raise_on_tee_shaped(a, b): return top, left, bottom - top, right - left + def _span_to_width(self, grid_width, top_tc, vMerge): + """ + Incorporate and then remove `w:tc` elements to the right of this one + until this cell spans *grid_width*. Raises |ValueError| if + *grid_width* cannot be exactly achieved, such as when a merged cell + would drive the span width greater than *grid_width* or if not enough + grid columns are available to make this cell that wide. All content + from incorporated cells is appended to *top_tc*. The val attribute of + the vMerge element on the single remaining cell is set to *vMerge*. + If *vMerge* is |None|, the vMerge element is removed if present. + """ + raise NotImplementedError + @property def _tbl(self): """ diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 487a54e1c..e6f95e22d 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -16,7 +16,7 @@ from ..unitutil.cxml import element from ..unitutil.file import snippet_seq -from ..unitutil.mock import instance_mock, method_mock, property_mock +from ..unitutil.mock import call, instance_mock, method_mock, property_mock class DescribeCT_Row(object): @@ -61,6 +61,11 @@ def it_raises_on_invalid_span(self, span_raise_fixture): with pytest.raises(InvalidSpanError): tc._span_dimensions(other_tc) + def it_can_grow_itself_to_help_merge(self, grow_to_fixture): + tc, width, height, top_tc, expected_calls = grow_to_fixture + tc._grow_to(width, height, top_tc) + assert tc._span_to_width.call_args_list == expected_calls + def it_raises_on_tr_above(self, tr_above_raise_fixture): tc = tr_above_raise_fixture with pytest.raises(ValueError): @@ -84,6 +89,28 @@ def extents_fixture(self, request): tc = tbl.tr_lst[row].tc_lst[col] return tc, attr_name, expected_value + @pytest.fixture(params=[ + (0, 0, 0, 2, 1), + (0, 0, 1, 1, 2), + (0, 1, 1, 2, 2), + (1, 0, 0, 2, 2), + (2, 0, 0, 2, 2), + (2, 1, 2, 1, 2), + ]) + def grow_to_fixture(self, request, _span_to_width_): + snippet_idx, row, col, width, height = request.param + tbl = self._snippet_tbl(snippet_idx) + tc = tbl.tr_lst[row].tc_lst[col] + start = 0 if height == 1 else 1 + end = start + height + expected_calls = [ + call(width, tc, None), + call(width, tc, 'restart'), + call(width, tc, 'continue'), + call(width, tc, 'continue'), + ][start:end] + return tc, width, height, None, expected_calls + @pytest.fixture def merge_fixture( self, tr_, _span_dimensions_, _tbl_, _grow_to_, top_tc_): @@ -147,6 +174,10 @@ def _grow_to_(self, request): def _span_dimensions_(self, request): return method_mock(request, CT_Tc, '_span_dimensions') + @pytest.fixture + def _span_to_width_(self, request): + return method_mock(request, CT_Tc, '_span_to_width') + def _snippet_tbl(self, idx): """ Return a element for snippet at *idx* in 'tbl-cells' snippet diff --git a/tests/test_files/snippets/tbl-cells.txt b/tests/test_files/snippets/tbl-cells.txt index 9f2176a05..f1d773100 100644 --- a/tests/test_files/snippets/tbl-cells.txt +++ b/tests/test_files/snippets/tbl-cells.txt @@ -1,4 +1,14 @@ - + @@ -22,7 +32,17 @@ - + @@ -49,7 +69,17 @@ - + @@ -81,7 +111,17 @@ - + @@ -113,7 +153,17 @@ - + @@ -156,7 +206,17 @@ - + @@ -182,7 +242,17 @@ - + From 25f1519ec0c52f2788b12d2f300353b0d410d419 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 24 Nov 2014 22:42:39 -0800 Subject: [PATCH 225/809] tbl: add CT_Tc._span_to_width() --- docx/oxml/table.py | 33 +++++++++++++++++++++++++++++++++ tests/oxml/test_table.py | 32 +++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 74a7f8e10..1051c3159 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -314,6 +314,11 @@ def vMerge(self): return None return tcPr.vMerge_val + @vMerge.setter + def vMerge(self, value): + tcPr = self.get_or_add_tcPr() + tcPr.vMerge_val = value + @property def width(self): """ @@ -367,6 +372,13 @@ def _insert_tcPr(self, tcPr): self.insert(0, tcPr) return tcPr + def _move_content_to(self, other_tc): + """ + Append the content of this cell to *other_tc*, leaving this cell with + a single empty ```` element. + """ + raise NotImplementedError + def _new_tbl(self): return CT_Tbl.new() @@ -412,6 +424,21 @@ def _span_to_width(self, grid_width, top_tc, vMerge): the vMerge element on the single remaining cell is set to *vMerge*. If *vMerge* is |None|, the vMerge element is removed if present. """ + self._move_content_to(top_tc) + while self.grid_span < grid_width: + self._swallow_next_tc(grid_width, top_tc) + self.vMerge = vMerge + + def _swallow_next_tc(self, grid_width, top_tc): + """ + Extend the horizontal span of this `w:tc` element to incorporate the + following `w:tc` element in the row and then delete that following + `w:tc` element. Any content in the following `w:tc` element is + appended to the content of *top_tc*. The width of the following + `w:tc` element is added to this one, if present. Raises + |InvalidSpanError| if the width of the resulting cell is greater than + *grid_width* or if there is no next `` element in the row. + """ raise NotImplementedError @property @@ -515,6 +542,12 @@ def vMerge_val(self): return None return vMerge.val + @vMerge_val.setter + def vMerge_val(self, value): + self._remove_vMerge() + if value is not None: + self._add_vMerge().val = value + @property def width(self): """ diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index e6f95e22d..dc265c23c 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -66,6 +66,13 @@ def it_can_grow_itself_to_help_merge(self, grow_to_fixture): tc._grow_to(width, height, top_tc) assert tc._span_to_width.call_args_list == expected_calls + def it_can_extend_its_horz_span_to_help_merge(self, span_width_fixture): + tc, grid_width, top_tc, vMerge, expected_calls = span_width_fixture + tc._span_to_width(grid_width, top_tc, vMerge) + tc._move_content_to.assert_called_once_with(top_tc) + assert tc._swallow_next_tc.call_args_list == expected_calls + assert tc.vMerge == vMerge + def it_raises_on_tr_above(self, tr_above_raise_fixture): tc = tr_above_raise_fixture with pytest.raises(ValueError): @@ -154,9 +161,20 @@ def span_raise_fixture(self, request): tbl = self._snippet_tbl(snippet_idx) tc = tbl.tr_lst[row].tc_lst[col] tc_2 = tbl.tr_lst[row_2].tc_lst[col_2] - print(tc.top, tc_2.top, tc.bottom, tc_2.bottom) return tc, tc_2 + @pytest.fixture + def span_width_fixture( + self, top_tc_, grid_span_, _move_content_to_, _swallow_next_tc_): + tc = element('w:tc') + grid_span_.side_effect = [1, 3, 4] + grid_width, vMerge = 4, 'continue' + expected_calls = [ + call(grid_width, top_tc_), + call(grid_width, top_tc_) + ] + return tc, grid_width, top_tc_, vMerge, expected_calls + @pytest.fixture(params=[(0, 0, 0), (4, 0, 0)]) def tr_above_raise_fixture(self, request): snippet_idx, row_idx, col_idx = request.param @@ -166,10 +184,18 @@ def tr_above_raise_fixture(self, request): # fixture components --------------------------------------------- + @pytest.fixture + def grid_span_(self, request): + return property_mock(request, CT_Tc, 'grid_span') + @pytest.fixture def _grow_to_(self, request): return method_mock(request, CT_Tc, '_grow_to') + @pytest.fixture + def _move_content_to_(self, request): + return method_mock(request, CT_Tc, '_move_content_to') + @pytest.fixture def _span_dimensions_(self, request): return method_mock(request, CT_Tc, '_span_dimensions') @@ -185,6 +211,10 @@ def _snippet_tbl(self, idx): """ return parse_xml(snippet_seq('tbl-cells')[idx]) + @pytest.fixture + def _swallow_next_tc_(self, request): + return method_mock(request, CT_Tc, '_swallow_next_tc') + @pytest.fixture def _tbl_(self, request): return property_mock(request, CT_Tc, '_tbl') From cd09fab79fb625be49c77ed5df20d3f94e1c92ee Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 25 Nov 2014 00:25:01 -0800 Subject: [PATCH 226/809] tbl: add CT_Tc._move_content_to() --- docx/oxml/table.py | 50 ++++++++++++++++++++++++++++++++++++++-- tests/oxml/test_table.py | 29 ++++++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 1051c3159..43079a5d7 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -8,7 +8,7 @@ from . import parse_xml from ..exceptions import InvalidSpanError -from .ns import nsdecls +from .ns import nsdecls, qn from ..shared import Emu, Twips from .simpletypes import ( ST_Merge, ST_TblLayoutType, ST_TblWidth, ST_TwipsMeasure, XsdInt @@ -254,6 +254,16 @@ def grid_span(self): return 1 return tcPr.grid_span + 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')) + for child in self: + if child.tag in block_item_tags: + yield child + @property def left(self): """ @@ -372,16 +382,52 @@ def _insert_tcPr(self, tcPr): self.insert(0, tcPr) return tcPr + @property + def _is_empty(self): + """ + True if this cell contains only a single empty ```` element. + """ + block_items = list(self.iter_block_items()) + if len(block_items) > 1: + return False + p = block_items[0] # cell must include at least one element + if len(p.r_lst) == 0: + return True + return False + def _move_content_to(self, other_tc): """ Append the content of this cell to *other_tc*, leaving this cell with a single empty ```` element. """ - raise NotImplementedError + if other_tc is self: + return + if self._is_empty: + return + other_tc._remove_trailing_empty_p() + # appending moves each element from self to other_tc + for block_element in self.iter_block_items(): + other_tc.append(block_element) + # add back the required minimum single empty element + self.append(self._new_p()) def _new_tbl(self): return CT_Tbl.new() + def _remove_trailing_empty_p(self): + """ + Remove the last content element from this cell if it is an empty + ```` element. + """ + block_items = list(self.iter_block_items()) + last_content_elm = block_items[-1] + if last_content_elm.tag != qn('w:p'): + return + p = last_content_elm + if len(p.r_lst) > 0: + return + self.remove(p) + def _span_dimensions(self, other_tc): """ Return a (top, left, height, width) 4-tuple specifying the extents of diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index dc265c23c..13635beea 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -14,7 +14,7 @@ from docx.oxml import parse_xml from docx.oxml.table import CT_Row, CT_Tc -from ..unitutil.cxml import element +from ..unitutil.cxml import element, xml from ..unitutil.file import snippet_seq from ..unitutil.mock import call, instance_mock, method_mock, property_mock @@ -73,6 +73,12 @@ def it_can_extend_its_horz_span_to_help_merge(self, span_width_fixture): assert tc._swallow_next_tc.call_args_list == expected_calls assert tc.vMerge == vMerge + def it_can_move_its_content_to_help_merge(self, move_fixture): + tc, tc_2, expected_tc_xml, expected_tc_2_xml = move_fixture + tc._move_content_to(tc_2) + assert tc.xml == expected_tc_xml + assert tc_2.xml == expected_tc_2_xml + def it_raises_on_tr_above(self, tr_above_raise_fixture): tc = tr_above_raise_fixture with pytest.raises(ValueError): @@ -128,6 +134,27 @@ def merge_fixture( tr_.tc_at_grid_col.return_value = top_tc_ return tc, other_tc, tr_, top_tc_, left, height, width + @pytest.fixture(params=[ + ('w:tc/w:p', 'w:tc/w:p', + 'w:tc/w:p', 'w:tc/w:p'), + ('w:tc/w:p', 'w:tc/w:p/w:r', + 'w:tc/w:p', 'w:tc/w:p/w:r'), + ('w:tc/w:p/w:r', 'w:tc/w:p', + 'w:tc/w:p', 'w:tc/w:p/w:r'), + ('w:tc/(w:p/w:r,w:sdt)', 'w:tc/w:p', + 'w:tc/w:p', 'w:tc/(w:p/w:r,w:sdt)'), + ('w:tc/(w:p/w:r,w:sdt)', 'w:tc/(w:tbl,w:p)', + 'w:tc/w:p', 'w:tc/(w:tbl,w:p/w:r,w:sdt)'), + ]) + def move_fixture(self, request): + tc_cxml, tc_2_cxml, expected_tc_cxml, expected_tc_2_cxml = ( + request.param + ) + tc, tc_2 = element(tc_cxml), element(tc_2_cxml) + expected_tc_xml = xml(expected_tc_cxml) + expected_tc_2_xml = xml(expected_tc_2_cxml) + return tc, tc_2, expected_tc_xml, expected_tc_2_xml + @pytest.fixture(params=[ (0, 0, 0, 0, 1, (0, 0, 1, 2)), (0, 0, 1, 2, 1, (0, 1, 3, 1)), From b0061eb0bc219391e23c2007d331f310b5c911b3 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Tue, 25 Nov 2014 20:39:34 -0800 Subject: [PATCH 227/809] tbl: add CT_Tc._swallow_next_tc() --- docx/oxml/table.py | 47 ++++++++++++++++++++- features/tbl-merge-cells.feature | 4 -- tests/oxml/test_table.py | 71 ++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 43079a5d7..9fde25de8 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -254,6 +254,11 @@ def grid_span(self): return 1 return tcPr.grid_span + @grid_span.setter + def grid_span(self, value): + tcPr = self.get_or_add_tcPr() + tcPr.grid_span = value + def iter_block_items(self): """ Generate a reference to each of the block-level content elements in @@ -345,6 +350,14 @@ def width(self, value): tcPr = self.get_or_add_tcPr() tcPr.width = value + def _add_width_of(self, other_tc): + """ + Add the width of *other_tc* to this cell. Does nothing if either this + tc or *other_tc* does not have a specified width. + """ + if self.width and other_tc.width: + self.width += other_tc.width + @property def _grid_col(self): """ @@ -414,6 +427,21 @@ def _move_content_to(self, other_tc): def _new_tbl(self): return CT_Tbl.new() + @property + 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') + return following_tcs[0] if following_tcs else None + + def _remove(self): + """ + Remove this `w:tc` element from the XML tree. + """ + self.getparent().remove(self) + def _remove_trailing_empty_p(self): """ Remove the last content element from this cell if it is an empty @@ -485,7 +513,18 @@ 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. """ - raise NotImplementedError + def raise_on_invalid_swallow(next_tc): + if next_tc is None: + raise InvalidSpanError('not enough grid columns') + if self.grid_span + next_tc.grid_span > grid_width: + raise InvalidSpanError('span is not rectangular') + + next_tc = self._next_tc + raise_on_invalid_swallow(next_tc) + next_tc._move_content_to(top_tc) + self._add_width_of(next_tc) + self.grid_span += next_tc.grid_span + next_tc._remove() @property def _tbl(self): @@ -577,6 +616,12 @@ def grid_span(self): return 1 return gridSpan.val + @grid_span.setter + def grid_span(self, value): + self._remove_gridSpan() + if value > 1: + self.get_or_add_gridSpan().val = value + @property def vMerge_val(self): """ diff --git a/features/tbl-merge-cells.feature b/features/tbl-merge-cells.feature index 3249f81e7..8c8b69eef 100644 --- a/features/tbl-merge-cells.feature +++ b/features/tbl-merge-cells.feature @@ -3,7 +3,6 @@ Feature: Merge table cells As a developer using python-docx I need a way to merge a range of cells - @wip Scenario Outline: Merge cells Given a 3x3 table having only uniform cells When I merge from cell to cell @@ -16,7 +15,6 @@ Feature: Merge table cells | 5 | 9 | 1 2 3 4 5\6\8\9 5\6\8\9 7 5\6\8\9 5\6\8\9 | - @wip Scenario Outline: Merge horizontal span with other cell Given a 3x3 table having a horizontal span When I merge from cell to cell @@ -29,7 +27,6 @@ Feature: Merge table cells | 2 | 4 | 1\2\4 1\2\4 3 1\2\4 1\2\4 6 7 8 9 | - @wip Scenario Outline: Merge vertical span with other cell Given a 3x3 table having a vertical span When I merge from cell to cell @@ -42,7 +39,6 @@ Feature: Merge table cells | 7 | 5 | 1 2 3 4\5\7 4\5\7 6 4\5\7 4\5\7 9 | - @wip Scenario Outline: Horizontal span adds cell widths Given a 3x3 table having When I merge from cell to cell diff --git a/tests/oxml/test_table.py b/tests/oxml/test_table.py index 13635beea..5a7f68634 100644 --- a/tests/oxml/test_table.py +++ b/tests/oxml/test_table.py @@ -73,6 +73,21 @@ def it_can_extend_its_horz_span_to_help_merge(self, span_width_fixture): assert tc._swallow_next_tc.call_args_list == expected_calls assert tc.vMerge == vMerge + def it_can_swallow_the_next_tc_help_merge(self, swallow_fixture): + tc, grid_width, top_tc, tr, expected_xml = swallow_fixture + tc._swallow_next_tc(grid_width, top_tc) + assert tr.xml == expected_xml + + def it_adds_cell_widths_on_swallow(self, add_width_fixture): + tc, grid_width, top_tc, tr, expected_xml = add_width_fixture + tc._swallow_next_tc(grid_width, top_tc) + assert tr.xml == expected_xml + + def it_raises_on_invalid_swallow(self, swallow_raise_fixture): + tc, grid_width, top_tc, tr = swallow_raise_fixture + with pytest.raises(InvalidSpanError): + tc._swallow_next_tc(grid_width, top_tc) + def it_can_move_its_content_to_help_merge(self, move_fixture): tc, tc_2, expected_tc_xml, expected_tc_2_xml = move_fixture tc._move_content_to(tc_2) @@ -86,6 +101,32 @@ def it_raises_on_tr_above(self, tr_above_raise_fixture): # fixtures ------------------------------------------------------- + @pytest.fixture(params=[ + # both cells have a width + ('w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p),' + 'w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))', 0, 2, + 'w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=2880,w:type=dxa},' + 'w:gridSpan{w:val=2}),w:p))'), + # neither have a width + ('w:tr/(w:tc/w:p,w:tc/w:p)', 0, 2, + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))'), + # only second one has a width + ('w:tr/(w:tc/w:p,' + 'w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p))', 0, 2, + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))'), + # only first one has a width + ('w:tr/(w:tc/(w:tcPr/w:tcW{w:w=1440,w:type=dxa},w:p),' + 'w:tc/w:p)', 0, 2, + 'w:tr/(w:tc/(w:tcPr/(w:tcW{w:w=1440,w:type=dxa},' + 'w:gridSpan{w:val=2}),w:p))'), + ]) + def add_width_fixture(self, request): + tr_cxml, tc_idx, grid_width, expected_tr_cxml = request.param + tr = element(tr_cxml) + tc = top_tc = tr[tc_idx] + expected_tr_xml = xml(expected_tr_cxml) + return tc, grid_width, top_tc, tr, expected_tr_xml + @pytest.fixture(params=[ (0, 0, 0, 'top', 0), (2, 0, 1, 'top', 0), (2, 1, 1, 'top', 0), (4, 2, 1, 'top', 1), @@ -202,6 +243,36 @@ def span_width_fixture( ] return tc, grid_width, top_tc_, vMerge, expected_calls + @pytest.fixture(params=[ + ('w:tr/(w:tc/w:p,w:tc/w:p)', 0, 2, + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))'), + ('w:tr/(w:tc/w:p,w:tc/w:p,w:tc/w:p)', 1, 2, + 'w:tr/(w:tc/w:p,w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))'), + ('w:tr/(w:tc/w:p/w:r/w:t"a",w:tc/w:p/w:r/w:t"b")', 0, 2, + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p/w:r/w:t"a",' + 'w:p/w:r/w:t"b"))'), + ('w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p),w:tc/w:p)', 0, 3, + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=3},w:p))'), + ('w:tr/(w:tc/w:p,w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))', 0, 3, + 'w:tr/(w:tc/(w:tcPr/w:gridSpan{w:val=3},w:p))'), + ]) + def swallow_fixture(self, request): + tr_cxml, tc_idx, grid_width, expected_tr_cxml = request.param + tr = element(tr_cxml) + tc = top_tc = tr[tc_idx] + expected_tr_xml = xml(expected_tr_cxml) + return tc, grid_width, top_tc, tr, expected_tr_xml + + @pytest.fixture(params=[ + ('w:tr/w:tc/w:p', 0, 2), + ('w:tr/(w:tc/w:p,w:tc/(w:tcPr/w:gridSpan{w:val=2},w:p))', 0, 2), + ]) + def swallow_raise_fixture(self, request): + tr_cxml, tc_idx, grid_width = request.param + tr = element(tr_cxml) + tc = top_tc = tr[tc_idx] + return tc, grid_width, top_tc, tr + @pytest.fixture(params=[(0, 0, 0), (4, 0, 0)]) def tr_above_raise_fixture(self, request): snippet_idx, row_idx, col_idx = request.param From dbba25003ae7d4ab2dfde7baa8de5700bcb33ac1 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Wed, 26 Nov 2014 03:48:52 -0500 Subject: [PATCH 228/809] docs: update documentation for merge() --- docs/api/table.rst | 1 + docs/conf.py | 2 ++ docx/table.py | 5 ++--- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/api/table.rst b/docs/api/table.rst index e3c9da952..215bf807c 100644 --- a/docs/api/table.rst +++ b/docs/api/table.rst @@ -15,6 +15,7 @@ Table objects are constructed using the ``add_table()`` method on |Document|. .. autoclass:: Table :members: + :exclude-members: table |_Cell| objects diff --git a/docs/conf.py b/docs/conf.py index 5fb91ca12..8dac74384 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -89,6 +89,8 @@ .. |InlineShapes| replace:: :class:`.InlineShapes` +.. |InvalidSpanError| replace:: :class:`.InvalidSpanError` + .. |int| replace:: :class:`int` .. |Length| replace:: :class:`.Length` diff --git a/docx/table.py b/docx/table.py index 533f2b47b..53139d328 100644 --- a/docx/table.py +++ b/docx/table.py @@ -182,9 +182,8 @@ def add_table(self, rows, cols): def merge(self, other_cell): """ Return a merged cell created by spanning the rectangular region - demarcated by using the extents of this cell and *other_cell* as - diagonal corners. Raises |InvalidSpanError| if the cells do not - define a rectangular region. + having this cell and *other_cell* as diagonal corners. Raises + |InvalidSpanError| if the cells do not define a rectangular region. """ tc, tc_2 = self._tc, other_cell._tc merged_tc = tc.merge(tc_2) From e9345cc0d32ea81be9b715b757777fcdca762137 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Fri, 28 Nov 2014 01:45:57 -0500 Subject: [PATCH 229/809] tbl: remove dead tbl param from _Column.__init__ --- docx/table.py | 9 ++++----- tests/test_table.py | 10 +++++----- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docx/table.py b/docx/table.py index 53139d328..5123457b1 100644 --- a/docx/table.py +++ b/docx/table.py @@ -27,7 +27,7 @@ def add_column(self): gridCol = tblGrid.add_gridCol() for tr in self._tbl.tr_lst: tr.add_tc() - return _Column(gridCol, self._tbl, self) + return _Column(gridCol, self) def add_row(self): """ @@ -242,10 +242,9 @@ class _Column(Parented): """ Table column """ - def __init__(self, gridCol, tbl, parent): + def __init__(self, gridCol, parent): super(_Column, self).__init__(parent) self._gridCol = gridCol - self._tbl = tbl @property def cells(self): @@ -299,11 +298,11 @@ def __getitem__(self, idx): except IndexError: msg = "column index [%d] is out of range" % idx raise IndexError(msg) - return _Column(gridCol, self._tbl, self) + return _Column(gridCol, self) def __iter__(self): for gridCol in self._gridCol_lst: - yield _Column(gridCol, self._tbl, self) + yield _Column(gridCol, self) def __len__(self): return len(self._gridCol_lst) diff --git a/tests/test_table.py b/tests/test_table.py index c4e626402..0aeb46529 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -447,7 +447,7 @@ def it_knows_its_index_in_table_to_help(self, index_fixture): @pytest.fixture def cells_fixture(self, _index_, table_prop_, table_): - column = _Column(None, None, None) + column = _Column(None, None) _index_.return_value = column_idx = 4 expected_cells = (3, 2, 1) table_.column_cells.return_value = list(expected_cells) @@ -457,12 +457,12 @@ def cells_fixture(self, _index_, table_prop_, table_): def index_fixture(self): tbl = element('w:tbl/w:tblGrid/(w:gridCol,w:gridCol,w:gridCol)') gridCol, expected_idx = tbl.tblGrid[1], 1 - column = _Column(gridCol, tbl, None) + column = _Column(gridCol, None) return column, expected_idx @pytest.fixture def table_fixture(self, parent_, table_): - column = _Column(None, None, parent_) + column = _Column(None, parent_) parent_.table = table_ return column, table_ @@ -476,7 +476,7 @@ def table_fixture(self, parent_, table_): ]) def width_get_fixture(self, request): gridCol_cxml, expected_width = request.param - column = _Column(element(gridCol_cxml), None, None) + column = _Column(element(gridCol_cxml), None) return column, expected_width @pytest.fixture(params=[ @@ -487,7 +487,7 @@ def width_get_fixture(self, request): ]) def width_set_fixture(self, request): gridCol_cxml, new_value, expected_cxml = request.param - column = _Column(element(gridCol_cxml), None, None) + column = _Column(element(gridCol_cxml), None) expected_xml = xml(expected_cxml) return column, new_value, expected_xml From 611e87757aff60eeba987f63f7bc364f2b90f190 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 29 Nov 2014 00:10:48 -0500 Subject: [PATCH 230/809] release: prepare v0.7.5 release --- HISTORY.rst | 6 ++++++ docx/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 925cd95be..0e0537148 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,12 @@ Release History --------------- +0.7.5 (2014-11-29) +++++++++++++++++++ + +- Add feature #65: _Cell.merge() + + 0.7.4 (2014-07-18) ++++++++++++++++++ diff --git a/docx/__init__.py b/docx/__init__.py index 4e4fdfda0..a738c0781 100644 --- a/docx/__init__.py +++ b/docx/__init__.py @@ -2,7 +2,7 @@ from docx.api import Document # noqa -__version__ = '0.7.4' +__version__ = '0.7.5' # register custom Part classes with opc package reader From 537f7304116be7d5d5d5bde79aa7c004c578655f Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 1 Dec 2014 17:39:50 -0500 Subject: [PATCH 231/809] docs: document Table.alignment analysis --- docs/dev/analysis/features/table-props.rst | 36 +++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/docs/dev/analysis/features/table-props.rst b/docs/dev/analysis/features/table-props.rst index b2f8fbeba..8485c7bc8 100644 --- a/docs/dev/analysis/features/table-props.rst +++ b/docs/dev/analysis/features/table-props.rst @@ -3,6 +3,23 @@ Table Properties ================ +Alignment +--------- + +Word allows a table to be aligned between the page margins either left, +right, or center. + +The read/write :attr:`Table.alignment` property specifies the alignment for +a table:: + + >>> table = document.add_table(rows=2, cols=2) + >>> table.alignment + None + >>> table.alignment = WD_TABLE_ALIGNMENT.RIGHT + >>> table.alignment + RIGHT (2) + + Autofit ------- @@ -28,12 +45,13 @@ Specimen XML .. highlight:: xml -The following XML is generated by Word when inserting a 2x2 table:: +The following XML represents a 2x2 table:: + @@ -151,6 +169,22 @@ Schema Definitions + + + + + + + + + + + + + + + + From e22465d048a05a587c2c7c0f7d33600a2bb25e75 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 1 Dec 2014 18:23:22 -0500 Subject: [PATCH 232/809] acpt: add scenarios for Table.alignment --- docs/api/enum/WdRowAlignment.rst | 24 ++++++++++++++ docs/api/enum/index.rst | 1 + docx/enum/table.py | 38 +++++++++++++++++++++++ features/steps/table.py | 38 +++++++++++++++++++++++ features/steps/test_files/tbl-props.docx | Bin 13981 -> 20094 bytes features/tbl-props.feature | 27 ++++++++++++++++ 6 files changed, 128 insertions(+) create mode 100644 docs/api/enum/WdRowAlignment.rst create mode 100644 docx/enum/table.py diff --git a/docs/api/enum/WdRowAlignment.rst b/docs/api/enum/WdRowAlignment.rst new file mode 100644 index 000000000..4459df5d3 --- /dev/null +++ b/docs/api/enum/WdRowAlignment.rst @@ -0,0 +1,24 @@ +.. _WdRowAlignment: + +``WD_TABLE_ALIGNMENT`` +====================== + +Specifies table justification type. + +Example:: + + from docx.enum.table import WD_TABLE_ALIGNMENT + + table = document.add_table(3, 3) + table.alignment = WD_TABLE_ALIGNMENT.CENTER + +---- + +LEFT + Left-aligned + +CENTER + Center-aligned. + +RIGHT + Right-aligned. diff --git a/docs/api/enum/index.rst b/docs/api/enum/index.rst index 576f45856..826994660 100644 --- a/docs/api/enum/index.rst +++ b/docs/api/enum/index.rst @@ -11,4 +11,5 @@ can be found here: WdAlignParagraph WdOrientation WdSectionStart + WdRowAlignment WdUnderline diff --git a/docx/enum/table.py b/docx/enum/table.py new file mode 100644 index 000000000..624d41a6f --- /dev/null +++ b/docx/enum/table.py @@ -0,0 +1,38 @@ +# encoding: utf-8 + +""" +Enumerations related to tables in WordprocessingML files +""" + +from __future__ import absolute_import, print_function, unicode_literals + +from .base import XmlEnumeration, XmlMappedEnumMember + + +class WD_TABLE_ALIGNMENT(XmlEnumeration): + """ + Specifies table justification type. + + Example:: + + from docx.enum.table import WD_TABLE_ALIGNMENT + + table = document.add_table(3, 3) + table.alignment = WD_TABLE_ALIGNMENT.CENTER + """ + + __ms_name__ = 'WdRowAlignment' + + __url__ = ' http://office.microsoft.com/en-us/word-help/HV080607259.aspx' + + __members__ = ( + XmlMappedEnumMember( + 'LEFT', 0, 'left', 'Left-aligned' + ), + XmlMappedEnumMember( + 'CENTER', 1, 'center', 'Center-aligned.' + ), + XmlMappedEnumMember( + 'RIGHT', 2, 'right', 'Right-aligned.' + ), + ) diff --git a/features/steps/table.py b/features/steps/table.py index 243f22d7b..938f38875 100644 --- a/features/steps/table.py +++ b/features/steps/table.py @@ -11,6 +11,7 @@ from behave import given, then, when from docx import Document +from docx.enum.table import WD_TABLE_ALIGNMENT from docx.shared import Inches from docx.table import _Column, _Columns, _Row, _Rows @@ -75,6 +76,19 @@ def given_a_table_having_a_width_of_width_desc(context, width_desc): context.column = document.tables[0].columns[col_idx] +@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') + document = Document(docx_path) + context.table_ = document.tables[table_idx] + + @given('a table having an applied style') def given_a_table_having_an_applied_style(context): docx_path = test_docx('tbl-having-applied-style') @@ -129,6 +143,18 @@ def when_apply_style_to_table(context): table.style = 'LightShading-Accent1' +@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, + }[value_str] + table = context.table_ + table.alignment = value + + @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): @@ -218,6 +244,18 @@ def then_can_iterate_over_row_collection(context): assert actual_count == 2 +@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, + }[value_str] + table = context.table_ + assert table.alignment == value, 'got %s' % table.alignment + + @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_ diff --git a/features/steps/test_files/tbl-props.docx b/features/steps/test_files/tbl-props.docx index fab223b3d035598b00d42b8a313bff7bbf7cb6b2..3ca129cea7fddc267ddd7240340874488e95ec66 100644 GIT binary patch delta 12452 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 alignment + Then table.alignment is + + Examples: table alignment settings + | alignment | value | + | inherited | None | + | left | WD_TABLE_ALIGNMENT.LEFT | + | right | WD_TABLE_ALIGNMENT.RIGHT | + | center | WD_TABLE_ALIGNMENT.CENTER | + + + @wip + Scenario Outline: Set table alignment + Given a table having alignment + When I assign to table.alignment + Then table.alignment is + + Examples: results of assignment to table.alignment + | alignment | value | + | inherited | WD_TABLE_ALIGNMENT.LEFT | + | left | WD_TABLE_ALIGNMENT.RIGHT | + | right | WD_TABLE_ALIGNMENT.CENTER | + | center | None | + + Scenario Outline: Get autofit layout setting Given a table having an autofit layout of Then the reported autofit setting is From a18b01326d39e9b8ada09eb543a1c79d9ff95c2e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 1 Dec 2014 18:46:47 -0500 Subject: [PATCH 233/809] tbl: reorder tests --- tests/test_table.py | 54 ++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/test_table.py b/tests/test_table.py index 0aeb46529..35bae77ad 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -23,6 +23,33 @@ class DescribeTable(object): + def it_knows_whether_it_should_autofit(self, autofit_get_fixture): + table, expected_value = autofit_get_fixture + assert table.autofit is expected_value + + def it_can_change_its_autofit_setting(self, autofit_set_fixture): + table, new_value, expected_xml = autofit_set_fixture + table.autofit = new_value + assert table._tbl.xml == expected_xml + + def it_knows_its_table_style(self, table_style_get_fixture): + table, style = table_style_get_fixture + assert table.style == style + + def it_can_apply_a_table_style_by_name(self, table_style_set_fixture): + table, style_name, expected_xml = table_style_set_fixture + table.style = style_name + assert table._tbl.xml == expected_xml + + def it_knows_it_is_the_table_its_children_belong_to(self, table_fixture): + table = table_fixture + assert table.table is table + + def it_knows_its_column_count_to_help(self, column_count_fixture): + table, expected_value = column_count_fixture + column_count = table._column_count + assert column_count == expected_value + def it_provides_access_to_the_table_rows(self, table): rows = table.rows assert isinstance(rows, _Rows) @@ -64,33 +91,6 @@ def it_can_add_a_column(self, add_column_fixture): assert isinstance(column, _Column) assert column._gridCol is table._tbl.tblGrid.gridCol_lst[1] - def it_knows_its_table_style(self, table_style_get_fixture): - table, style = table_style_get_fixture - assert table.style == style - - def it_can_apply_a_table_style_by_name(self, table_style_set_fixture): - table, style_name, expected_xml = table_style_set_fixture - table.style = style_name - assert table._tbl.xml == expected_xml - - def it_knows_whether_it_should_autofit(self, autofit_get_fixture): - table, expected_value = autofit_get_fixture - assert table.autofit is expected_value - - def it_can_change_its_autofit_setting(self, autofit_set_fixture): - table, new_value, expected_xml = autofit_set_fixture - table.autofit = new_value - assert table._tbl.xml == expected_xml - - def it_knows_it_is_the_table_its_children_belong_to(self, table_fixture): - table = table_fixture - assert table.table is table - - def it_knows_its_column_count_to_help(self, column_count_fixture): - table, expected_value = column_count_fixture - column_count = table._column_count - assert column_count == expected_value - def it_provides_access_to_its_cells_to_help(self, cells_fixture): table, cell_count, unique_count, matches = cells_fixture cells = table._cells From 572526bf55948c0bab51f6eff33b5dad0e190e9e Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 1 Dec 2014 19:08:54 -0500 Subject: [PATCH 234/809] tbl: add Table.alignment getter --- docx/oxml/table.py | 29 +++++++++++++++++++++-------- docx/table.py | 10 ++++++++++ features/tbl-props.feature | 1 - tests/test_table.py | 16 ++++++++++++++++ 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 9fde25de8..390a3316b 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -134,16 +134,29 @@ class CT_TblPr(BaseOxmlElement): ```` element, child of ````, holds child elements that define table properties such as style and borders. """ - tblStyle = ZeroOrOne('w:tblStyle', successors=( - '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' - )) - tblLayout = ZeroOrOne('w:tblLayout', successors=( + _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' - )) + ) + tblStyle = ZeroOrOne('w:tblStyle', successors=_tag_seq[1:]) + jc = ZeroOrOne('w:jc', successors=_tag_seq[8:]) + tblLayout = ZeroOrOne('w:tblLayout', successors=_tag_seq[13:]) + del _tag_seq + + @property + def alignment(self): + """ + Member of :ref:`WdRowAlignment` enumeration or |None|, based on the + contents of the `w:val` attribute of `./w:jc`. |None| if no `w:jc` + element is present. + """ + jc = self.jc + if jc is None: + return None + return jc.val @property def autofit(self): diff --git a/docx/table.py b/docx/table.py index 5123457b1..4d02f0860 100644 --- a/docx/table.py +++ b/docx/table.py @@ -39,6 +39,16 @@ def add_row(self): tr.add_tc() return _Row(tr, self) + @property + def alignment(self): + """ + Read/write. A member of :ref:`WdRowAlignment` or None, specifying the + positioning of this table between the page margins. |None| if no + setting is specified, causing the effective value to be inherited + from the style hierarchy. + """ + return self._tblPr.alignment + @property def autofit(self): """ diff --git a/features/tbl-props.feature b/features/tbl-props.feature index 4f9374a7e..6ffd72fbe 100644 --- a/features/tbl-props.feature +++ b/features/tbl-props.feature @@ -4,7 +4,6 @@ Feature: Get and set table properties I need a way to get and set a table's properties - @wip Scenario Outline: Determine table alignment Given a table having alignment Then table.alignment is diff --git a/tests/test_table.py b/tests/test_table.py index 35bae77ad..d1c05b9e2 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -8,6 +8,7 @@ import pytest +from docx.enum.table import WD_TABLE_ALIGNMENT from docx.oxml import parse_xml from docx.oxml.table import CT_Tc from docx.shared import Inches @@ -23,6 +24,10 @@ class DescribeTable(object): + def it_knows_its_alignment_setting(self, alignment_get_fixture): + table, expected_value = alignment_get_fixture + assert table.alignment == expected_value + def it_knows_whether_it_should_autofit(self, autofit_get_fixture): table, expected_value = autofit_get_fixture assert table.autofit is expected_value @@ -117,6 +122,17 @@ def add_row_fixture(self): expected_xml = _tbl_bldr(rows=2, cols=2).xml() return table, expected_xml + @pytest.fixture(params=[ + ('w:tbl/w:tblPr', None), + ('w:tbl/w:tblPr/w:jc{w:val=center}', WD_TABLE_ALIGNMENT.CENTER), + ('w:tbl/w:tblPr/w:jc{w:val=right}', WD_TABLE_ALIGNMENT.RIGHT), + ('w:tbl/w:tblPr/w:jc{w:val=left}', WD_TABLE_ALIGNMENT.LEFT), + ]) + def alignment_get_fixture(self, request): + tbl_cxml, expected_value = request.param + table = Table(element(tbl_cxml), None) + return table, expected_value + @pytest.fixture(params=[ ('w:tbl/w:tblPr', True), ('w:tbl/w:tblPr/w:tblLayout', True), From f9e033e2dc41fe294899a1f682fca8e0c8d39d96 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Mon, 1 Dec 2014 19:17:36 -0500 Subject: [PATCH 235/809] tbl: add Table.alignment.setter --- docx/oxml/table.py | 8 ++++++++ docx/table.py | 4 ++++ features/tbl-props.feature | 1 - tests/test_table.py | 19 +++++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/docx/oxml/table.py b/docx/oxml/table.py index 390a3316b..c932b2f26 100644 --- a/docx/oxml/table.py +++ b/docx/oxml/table.py @@ -158,6 +158,14 @@ def alignment(self): return None return jc.val + @alignment.setter + def alignment(self, value): + self._remove_jc() + if value is None: + return + jc = self.get_or_add_jc() + jc.val = value + @property def autofit(self): """ diff --git a/docx/table.py b/docx/table.py index 4d02f0860..424bee541 100644 --- a/docx/table.py +++ b/docx/table.py @@ -49,6 +49,10 @@ def alignment(self): """ return self._tblPr.alignment + @alignment.setter + def alignment(self, value): + self._tblPr.alignment = value + @property def autofit(self): """ diff --git a/features/tbl-props.feature b/features/tbl-props.feature index 6ffd72fbe..6d4fb4da4 100644 --- a/features/tbl-props.feature +++ b/features/tbl-props.feature @@ -16,7 +16,6 @@ Feature: Get and set table properties | center | WD_TABLE_ALIGNMENT.CENTER | - @wip Scenario Outline: Set table alignment Given a table having alignment When I assign to table.alignment diff --git a/tests/test_table.py b/tests/test_table.py index d1c05b9e2..107574688 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -28,6 +28,11 @@ def it_knows_its_alignment_setting(self, alignment_get_fixture): table, expected_value = alignment_get_fixture assert table.alignment == expected_value + def it_can_change_its_alignment_setting(self, alignment_set_fixture): + table, new_value, expected_xml = alignment_set_fixture + table.alignment = new_value + assert table._tbl.xml == expected_xml + def it_knows_whether_it_should_autofit(self, autofit_get_fixture): table, expected_value = autofit_get_fixture assert table.autofit is expected_value @@ -133,6 +138,20 @@ def alignment_get_fixture(self, request): table = Table(element(tbl_cxml), None) return table, expected_value + @pytest.fixture(params=[ + ('w:tbl/w:tblPr', WD_TABLE_ALIGNMENT.LEFT, + 'w:tbl/w:tblPr/w:jc{w:val=left}'), + ('w:tbl/w:tblPr/w:jc{w:val=left}', WD_TABLE_ALIGNMENT.RIGHT, + 'w:tbl/w:tblPr/w:jc{w:val=right}'), + ('w:tbl/w:tblPr/w:jc{w:val=right}', None, + 'w:tbl/w:tblPr'), + ]) + def alignment_set_fixture(self, request): + tbl_cxml, new_value, expected_tbl_cxml = request.param + table = Table(element(tbl_cxml), None) + expected_xml = xml(expected_tbl_cxml) + return table, new_value, expected_xml + @pytest.fixture(params=[ ('w:tbl/w:tblPr', True), ('w:tbl/w:tblPr/w:tblLayout', True), From 215ecebacc4086e9215483ca7aa0115225d73d16 Mon Sep 17 00:00:00 2001 From: Steve Canny Date: Sat, 13 Dec 2014 12:28:15 -0800 Subject: [PATCH 236/809] tmpl: change core props author to python-docx --- docx/templates/default.docx | Bin 58399 -> 38024 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docx/templates/default.docx b/docx/templates/default.docx index 62c580eb5dc01fc7372a5f51b681b18a29883c6e..85201dd1d12caa32be32f1f10f50f75417c8e42d 100644 GIT binary patch literal 38024 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∓}W$t8_CN|y7 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 M5`n2mZ~}-203NHJE&u=k literal 58399 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