diff options
| -rw-r--r-- | NEWS | 4 | ||||
| -rw-r--r-- | mammoth/docx/body_xml.py | 54 | ||||
| -rw-r--r-- | mammoth/docx/complex_fields.py | 11 | ||||
| -rw-r--r-- | test-requirements.txt | 1 | ||||
| -rw-r--r-- | tests/docx/body_xml_tests.py | 160 | ||||
| -rw-r--r-- | tests/docx/document_matchers.py | 28 |
6 files changed, 255 insertions, 3 deletions
@@ -1,3 +1,7 @@ +# 1.3.2 + +* Handle hyperlinks created with complex fields. + # 1.3.1 * Handle absolute paths within zip files. This should fix an issue where some diff --git a/mammoth/docx/body_xml.py b/mammoth/docx/body_xml.py index 9895d9c..64f83d6 100644 --- a/mammoth/docx/body_xml.py +++ b/mammoth/docx/body_xml.py @@ -1,8 +1,10 @@ import contextlib +import re from .. import documents from .. import results from .. import lists +from . import complex_fields from .xmlparser import node_types, XmlElement from .styles_xml import Styles from .uris import uri_to_zip_entry_name @@ -77,9 +79,16 @@ def _create_reader(numbering, content_types, relationships, styles, docx_file, f is_underline = read_boolean_element(properties.find_child("w:u")) is_strikethrough = read_boolean_element(properties.find_child("w:strike")) + def add_complex_field_hyperlink(children): + hyperlink_href = current_hyperlink_href() + if hyperlink_href is None: + return children + else: + return [documents.hyperlink(href=hyperlink_href, children=children)] + return _ReadResult.map_results( _read_run_style(properties), - _read_xml_elements(element.children), + _read_xml_elements(element.children).map(add_complex_field_hyperlink), lambda style, children: documents.run( children=children, style_id=style[0], @@ -90,6 +99,9 @@ def _create_reader(numbering, content_types, relationships, styles, docx_file, f is_strikethrough=is_strikethrough, vertical_alignment=vertical_alignment, )) + + def _read_run_style(properties): + return _read_style(properties, "w:rStyle", "Run", styles.find_character_style_by_id) def read_boolean_element(element): return element and element.attributes.get("w:val") not in ["false", "0"] @@ -112,8 +124,42 @@ def _create_reader(numbering, content_types, relationships, styles, docx_file, f def _read_paragraph_style(properties): return _read_style(properties, "w:pStyle", "Paragraph", styles.find_paragraph_style_by_id) - def _read_run_style(properties): - return _read_style(properties, "w:rStyle", "Run", styles.find_character_style_by_id) + current_instr_text = [] + complex_field_stack = [] + + def current_hyperlink_href(): + for complex_field in reversed(complex_field_stack): + if isinstance(complex_field, complex_fields.Hyperlink): + return complex_field.href + + return None + + def read_fld_char(element): + fld_char_type = element.attributes.get("w:fldCharType") + if fld_char_type == "begin": + del current_instr_text[:] + elif fld_char_type == "end": + complex_field_stack.pop() + elif fld_char_type == "separate": + instr_text = "".join(current_instr_text) + hyperlink_href = parse_hyperlink_field_code(instr_text) + if hyperlink_href is None: + complex_field = complex_fields.unknown + else: + complex_field = complex_fields.hyperlink(hyperlink_href) + complex_field_stack.append(complex_field) + return _empty_result + + def parse_hyperlink_field_code(instr_text): + result = re.match(r'\s*HYPERLINK "(.*)"', instr_text) + if result is None: + return None + else: + return result.group(1) + + def read_instr_text(element): + current_instr_text.append(_inner_text(element)) + return _empty_result def _read_style(properties, style_tag_name, style_type, find_style_by_id): messages = [] @@ -359,6 +405,8 @@ def _create_reader(numbering, content_types, relationships, styles, docx_file, f "w:t": text, "w:r": run, "w:p": paragraph, + "w:fldChar": read_fld_char, + "w:instrText": read_instr_text, "w:tab": tab, "w:tbl": table, "w:tr": table_row, diff --git a/mammoth/docx/complex_fields.py b/mammoth/docx/complex_fields.py new file mode 100644 index 0000000..d1d95af --- /dev/null +++ b/mammoth/docx/complex_fields.py @@ -0,0 +1,11 @@ +class unknown(object): + pass + + +class Hyperlink(object): + def __init__(self, href): + self.href = href + + +def hyperlink(href): + return Hyperlink(href) diff --git a/test-requirements.txt b/test-requirements.txt index c4516c7..6609b73 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -5,3 +5,4 @@ tempman>=0.1.2,<0.2 pyflakes>=0.8.1,<0.9 nose-parameterized==0.5.0 PyHamcrest==1.9.0 +precisely==0.1.3 diff --git a/tests/docx/body_xml_tests.py b/tests/docx/body_xml_tests.py index c7f2c4b..f10bee4 100644 --- a/tests/docx/body_xml_tests.py +++ b/tests/docx/body_xml_tests.py @@ -1,5 +1,6 @@ import io +from precisely import assert_that, is_sequence from nose.tools import istest, assert_equal from nose_parameterized import parameterized, param import funk @@ -10,6 +11,13 @@ from mammoth.docx import body_xml from mammoth.docx.numbering_xml import Numbering from mammoth.docx.relationships_xml import Relationships, Relationship from mammoth.docx.styles_xml import Styles, Style +from .document_matchers import ( + is_paragraph, + is_empty_run, + is_run, + is_hyperlink, + is_text, +) @istest @@ -230,6 +238,158 @@ class RunTests(object): @istest +class ComplexFieldTests(object): + _URI = "http://example.com" + _BEGIN_COMPLEX_FIELD = xml_element("w:r", {}, [ + xml_element("w:fldChar", {"w:fldCharType": "begin"}), + ]) + _SEPARATE_COMPLEX_FIELD = xml_element("w:r", {}, [ + xml_element("w:fldChar", {"w:fldCharType": "separate"}), + ]) + _END_COMPLEX_FIELD = xml_element("w:r", {}, [ + xml_element("w:fldChar", {"w:fldCharType": "end"}), + ]) + _HYPERLINK_INSTRTEXT = xml_element("w:instrText", {}, [ + xml_text(' HYPERLINK "{0}"'.format(_URI)) + ]) + + def _is_hyperlinked_run(self, **kwargs): + return is_run(children=is_sequence( + is_hyperlink( + href=self._URI, + **kwargs + ), + )) + + @property + def _is_empty_hyperlinked_run(self): + return self._is_hyperlinked_run(children=[]) + + @istest + def runs_in_a_complex_field_for_hyperlinks_are_read_as_hyperlinks(self): + element = xml_element("w:p", {}, [ + self._BEGIN_COMPLEX_FIELD, + self._HYPERLINK_INSTRTEXT, + self._SEPARATE_COMPLEX_FIELD, + _run_element_with_text("this is a hyperlink"), + self._END_COMPLEX_FIELD, + ]) + paragraph = _read_and_get_document_xml_element(element) + + assert_that(paragraph, is_paragraph(children=is_sequence( + is_empty_run, + self._is_empty_hyperlinked_run, + self._is_hyperlinked_run(children=is_sequence( + is_text("this is a hyperlink"), + )), + is_empty_run, + ))) + + @istest + def runs_after_a_complex_field_for_hyperlinks_are_not_read_as_hyperlinks(self): + element = xml_element("w:p", {}, [ + self._BEGIN_COMPLEX_FIELD, + self._HYPERLINK_INSTRTEXT, + self._SEPARATE_COMPLEX_FIELD, + self._END_COMPLEX_FIELD, + _run_element_with_text("this will not be a hyperlink"), + ]) + paragraph = _read_and_get_document_xml_element(element) + + assert_that(paragraph, is_paragraph(children=is_sequence( + is_empty_run, + self._is_empty_hyperlinked_run, + is_empty_run, + is_run(children=is_sequence( + is_text("this will not be a hyperlink"), + )), + ))) + + @istest + def can_handle_split_instr_text_elements(self): + element = xml_element("w:p", {}, [ + self._BEGIN_COMPLEX_FIELD, + xml_element("w:instrText", {}, [ + xml_text(" HYPE") + ]), + xml_element("w:instrText", {}, [ + xml_text('RLINK "{0}"'.format(self._URI)), + ]), + self._SEPARATE_COMPLEX_FIELD, + _run_element_with_text("this is a hyperlink"), + self._END_COMPLEX_FIELD, + ]) + paragraph = _read_and_get_document_xml_element(element) + + assert_that(paragraph, is_paragraph(children=is_sequence( + is_empty_run, + self._is_empty_hyperlinked_run, + self._is_hyperlinked_run(children=is_sequence( + is_text("this is a hyperlink"), + )), + is_empty_run, + ))) + + @istest + def hyperlink_is_not_ended_by_end_of_nested_complex_field(self): + element = xml_element("w:p", {}, [ + self._BEGIN_COMPLEX_FIELD, + self._HYPERLINK_INSTRTEXT, + self._SEPARATE_COMPLEX_FIELD, + self._BEGIN_COMPLEX_FIELD, + xml_element("w:instrText", {}, [ + xml_text(' AUTHOR "John Doe"') + ]), + self._SEPARATE_COMPLEX_FIELD, + self._END_COMPLEX_FIELD, + _run_element_with_text("this is a hyperlink"), + self._END_COMPLEX_FIELD, + ]) + paragraph = _read_and_get_document_xml_element(element) + + assert_that(paragraph, is_paragraph(children=is_sequence( + is_empty_run, + self._is_empty_hyperlinked_run, + self._is_empty_hyperlinked_run, + self._is_empty_hyperlinked_run, + self._is_empty_hyperlinked_run, + self._is_hyperlinked_run(children=is_sequence( + is_text("this is a hyperlink"), + )), + is_empty_run, + ))) + + @istest + def complex_field_nested_within_a_hyperlink_complex_field_is_wrapped_with_the_hyperlink(self): + element = xml_element("w:p", {}, [ + self._BEGIN_COMPLEX_FIELD, + self._HYPERLINK_INSTRTEXT, + self._SEPARATE_COMPLEX_FIELD, + self._BEGIN_COMPLEX_FIELD, + xml_element("w:instrText", {}, [ + xml_text(' AUTHOR "John Doe"') + ]), + self._SEPARATE_COMPLEX_FIELD, + _run_element_with_text("John Doe"), + self._END_COMPLEX_FIELD, + self._END_COMPLEX_FIELD, + ]) + paragraph = _read_and_get_document_xml_element(element) + + assert_that(paragraph, is_paragraph(children=is_sequence( + is_empty_run, + self._is_empty_hyperlinked_run, + self._is_empty_hyperlinked_run, + self._is_empty_hyperlinked_run, + self._is_hyperlinked_run(children=is_sequence( + is_text("John Doe"), + )), + self._is_empty_hyperlinked_run, + is_empty_run, + ))) + + +@istest def can_read_tab_element(): element = xml_element("w:tab") tab = _read_and_get_document_xml_element(element) diff --git a/tests/docx/document_matchers.py b/tests/docx/document_matchers.py new file mode 100644 index 0000000..dd2276d --- /dev/null +++ b/tests/docx/document_matchers.py @@ -0,0 +1,28 @@ +from precisely import all_of, has_attrs, instance_of + +from mammoth import documents + + +def create_element_matcher(element_type): + def matcher(**kwargs): + return all_of( + instance_of(element_type), + has_attrs(**kwargs), + ) + + return matcher + + +is_paragraph = create_element_matcher(documents.Paragraph) +is_run = create_element_matcher(documents.Run) +is_hyperlink = create_element_matcher(documents.Hyperlink) + + +is_empty_run = is_run(children=[]) + + +def is_text(value): + return all_of( + instance_of(documents.Text), + has_attrs(value=value), + ) |
