summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMichael Williamson <mike@zwobble.org>2017-02-19 21:35:42 +0000
committerMichael Williamson <mike@zwobble.org>2017-02-19 21:35:42 +0000
commit74bbea738776bad4ddbcc1af19a44114e1defb07 (patch)
tree50b8d9e9887f2772b989b97fd0d00ec190ad6941
parentc8f835e3ed5d464f7c6bf15d77dce40918afd11a (diff)
Handle hyperlinks created with complex fields
-rw-r--r--NEWS4
-rw-r--r--mammoth/docx/body_xml.py54
-rw-r--r--mammoth/docx/complex_fields.py11
-rw-r--r--test-requirements.txt1
-rw-r--r--tests/docx/body_xml_tests.py160
-rw-r--r--tests/docx/document_matchers.py28
6 files changed, 255 insertions, 3 deletions
diff --git a/NEWS b/NEWS
index 80cdce4..c74e2bb 100644
--- a/NEWS
+++ b/NEWS
@@ -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),
+ )