├── tests ├── __init__.py ├── docx │ ├── __init__.py │ ├── uris_tests.py │ ├── document_matchers.py │ ├── notes_xml_tests.py │ ├── office_xml_tests.py │ ├── comments_xml_tests.py │ ├── document_xml_tests.py │ ├── relationships_xml_tests.py │ ├── content_types_xml_tests.py │ ├── files_tests.py │ ├── xmlparser_tests.py │ ├── style_map_tests.py │ ├── styles_xml_tests.py │ ├── numbering_xml_tests.py │ └── docx_tests.py ├── html │ ├── __init__.py │ ├── strip_empty_tests.py │ └── collapse_tests.py ├── styles │ ├── __init__.py │ ├── parser │ │ ├── __init__.py │ │ ├── style_mapping_parser_tests.py │ │ ├── token_parser_tests.py │ │ ├── html_path_parser_tests.py │ │ ├── tokeniser_tests.py │ │ └── document_matcher_parser_tests.py │ └── document_matcher_tests.py ├── writers │ ├── __init__.py │ └── markdown_tests.py ├── test-data │ ├── empty.docx │ ├── tables.docx │ ├── comments.docx │ ├── endnotes.docx │ ├── footnotes.docx │ ├── text-box.docx │ ├── underline.docx │ ├── utf8-bom.docx │ ├── simple-list.docx │ ├── tiny-picture.docx │ ├── tiny-picture.png │ ├── strict-format.docx │ ├── strikethrough.docx │ ├── external-picture.docx │ ├── single-paragraph.docx │ ├── embedded-style-map.docx │ ├── footnote-hyperlink.docx │ ├── tiny-picture-target-base-relative.docx │ ├── hyperlinks │ │ └── word │ │ │ ├── _rels │ │ │ └── document.xml.rels │ │ │ └── document.xml │ └── simple │ │ └── word │ │ └── document.xml ├── conftest.py ├── lists_tests.py ├── testing.py ├── zips_tests.py ├── options_tests.py ├── raw_text_tests.py ├── images_tests.py ├── cli_tests.py └── transforms_tests.py ├── setup.cfg ├── mammoth ├── styles │ ├── parser │ │ ├── errors.py │ │ ├── __init__.py │ │ ├── style_mapping_parser.py │ │ ├── token_parser.py │ │ ├── tokeniser.py │ │ ├── token_iterator.py │ │ ├── html_path_parser.py │ │ └── document_matcher_parser.py │ └── __init__.py ├── underline.py ├── docx │ ├── uris.py │ ├── complex_fields.py │ ├── document_xml.py │ ├── comments_xml.py │ ├── notes_xml.py │ ├── relationships_xml.py │ ├── files.py │ ├── content_types_xml.py │ ├── office_xml.py │ ├── style_map.py │ ├── xmlparser.py │ ├── styles_xml.py │ ├── numbering_xml.py │ └── __init__.py ├── writers │ ├── __init__.py │ ├── abc.py │ ├── html.py │ └── markdown.py ├── raw_text.py ├── images.py ├── lists.py ├── results.py ├── html │ ├── nodes.py │ └── __init__.py ├── html_paths.py ├── transforms.py ├── __init__.py ├── zips.py ├── document_matchers.py ├── cli.py ├── options.py └── documents.py ├── .gitignore ├── pyproject.toml ├── test-requirements.txt ├── .github ├── pull_request_template.md ├── ISSUE_TEMPLATE.md └── workflows │ └── tests.yml ├── tox.ini ├── makefile ├── LICENSE ├── setup.py ├── recipes └── wmf_images.py └── NEWS /tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/docx/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/html/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/styles/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/styles/parser/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/writers/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /mammoth/styles/parser/errors.py: -------------------------------------------------------------------------------- 1 | class LineParseError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /README 3 | /_virtualenv 4 | /*.egg-info 5 | /.tox 6 | /MANIFEST 7 | /build 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /tests/test-data/empty.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/empty.docx -------------------------------------------------------------------------------- /tests/test-data/tables.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/tables.docx -------------------------------------------------------------------------------- /tests/test-data/comments.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/comments.docx -------------------------------------------------------------------------------- /tests/test-data/endnotes.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/endnotes.docx -------------------------------------------------------------------------------- /tests/test-data/footnotes.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/footnotes.docx -------------------------------------------------------------------------------- /tests/test-data/text-box.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/text-box.docx -------------------------------------------------------------------------------- /tests/test-data/underline.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/underline.docx -------------------------------------------------------------------------------- /tests/test-data/utf8-bom.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/utf8-bom.docx -------------------------------------------------------------------------------- /tests/test-data/simple-list.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/simple-list.docx -------------------------------------------------------------------------------- /tests/test-data/tiny-picture.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/tiny-picture.docx -------------------------------------------------------------------------------- /tests/test-data/tiny-picture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/tiny-picture.png -------------------------------------------------------------------------------- /tests/test-data/strict-format.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/strict-format.docx -------------------------------------------------------------------------------- /tests/test-data/strikethrough.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/strikethrough.docx -------------------------------------------------------------------------------- /tests/test-data/external-picture.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/external-picture.docx -------------------------------------------------------------------------------- /tests/test-data/single-paragraph.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/single-paragraph.docx -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | funk>=0.4,<0.5 2 | pytest 3 | precisely==0.1.3 4 | pyflakes==2.4.0 5 | spur.local>=0.3.7,<0.4 6 | tempman>=0.1.2,<0.2 7 | -------------------------------------------------------------------------------- /tests/test-data/embedded-style-map.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/embedded-style-map.docx -------------------------------------------------------------------------------- /tests/test-data/footnote-hyperlink.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/footnote-hyperlink.docx -------------------------------------------------------------------------------- /tests/test-data/tiny-picture-target-base-relative.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwilliamson/python-mammoth/HEAD/tests/test-data/tiny-picture-target-base-relative.docx -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | In general, pull requests are not currently accepted. 2 | 3 | Please instead submit an issue if you find a bug or would like to request a feature. 4 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import funk 2 | import pytest 3 | 4 | 5 | @pytest.fixture(name="mocks") 6 | def _fixture_mocks(): 7 | mocks = funk.Mocks() 8 | yield mocks 9 | mocks.verify() 10 | -------------------------------------------------------------------------------- /mammoth/underline.py: -------------------------------------------------------------------------------- 1 | from . import html 2 | 3 | 4 | def element(name): 5 | def convert_underline(nodes): 6 | return [html.collapsible_element(name, {}, nodes)] 7 | 8 | return convert_underline 9 | -------------------------------------------------------------------------------- /mammoth/styles/__init__.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | 4 | def style(document_matcher, html_path): 5 | return Style(document_matcher, html_path) 6 | 7 | 8 | Style = collections.namedtuple("Style", ["document_matcher", "html_path"]) 9 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37,py38,py39,py310,py311,py312,pypy3 3 | [testenv] 4 | changedir = {envtmpdir} 5 | deps=-r{toxinidir}/test-requirements.txt 6 | commands= 7 | py.test {toxinidir}/tests 8 | [pytest] 9 | python_classes = *Tests 10 | python_files = *_tests.py 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | If you're reporting a bug or requesting a feature, please include: 2 | * a minimal example document 3 | * the HTML output that you'd expect 4 | 5 | If you're reporting a bug, it's also useful to know what platform you're 6 | running on, including: 7 | 8 | * the version of Python 9 | * the operating system and version 10 | -------------------------------------------------------------------------------- /tests/lists_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth.lists import unique 2 | from .testing import assert_equal 3 | 4 | 5 | def test_unique_of_empty_list_is_empty_list(): 6 | assert_equal([], unique([])) 7 | 8 | 9 | def test_unique_removes_duplicates_while_preserving_order(): 10 | assert_equal(["apple", "banana"], unique(["apple", "banana", "apple"])) 11 | -------------------------------------------------------------------------------- /mammoth/docx/uris.py: -------------------------------------------------------------------------------- 1 | def uri_to_zip_entry_name(base, uri): 2 | if uri.startswith("/"): 3 | return uri[1:] 4 | else: 5 | return base + "/" + uri 6 | 7 | 8 | def replace_fragment(uri, fragment): 9 | hash_index = uri.find("#") 10 | if hash_index != -1: 11 | uri = uri[:hash_index] 12 | return uri + "#" + fragment 13 | -------------------------------------------------------------------------------- /mammoth/writers/__init__.py: -------------------------------------------------------------------------------- 1 | from .html import HtmlWriter 2 | from .markdown import MarkdownWriter 3 | 4 | 5 | def writer(output_format=None): 6 | if output_format is None: 7 | output_format = "html" 8 | 9 | return _writers[output_format]() 10 | 11 | 12 | def formats(): 13 | return _writers.keys() 14 | 15 | 16 | _writers = { 17 | "html": HtmlWriter, 18 | "markdown": MarkdownWriter, 19 | } 20 | -------------------------------------------------------------------------------- /mammoth/raw_text.py: -------------------------------------------------------------------------------- 1 | from . import documents 2 | 3 | 4 | def extract_raw_text_from_element(element): 5 | if isinstance(element, documents.Text): 6 | return element.value 7 | elif isinstance(element, documents.Tab): 8 | return "\t" 9 | else: 10 | text = "".join(map(extract_raw_text_from_element, getattr(element, "children", []))) 11 | if isinstance(element, documents.Paragraph): 12 | return text + "\n\n" 13 | else: 14 | return text 15 | -------------------------------------------------------------------------------- /tests/testing.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from precisely import assert_that, equal_to 4 | 5 | 6 | def generate_test_path(path): 7 | this_dir = os.path.dirname(__file__) 8 | return os.path.join(this_dir, "test-data", path) 9 | 10 | 11 | def assert_equal(expected, actual): 12 | assert_that(actual, equal_to(expected)) 13 | 14 | 15 | def assert_raises(exception, func): 16 | try: 17 | func() 18 | assert False, "Expected " + exception.__name__ 19 | except exception as error: 20 | return error 21 | 22 | -------------------------------------------------------------------------------- /tests/docx/uris_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth.docx.uris import uri_to_zip_entry_name 2 | from ..testing import assert_equal 3 | 4 | 5 | def test_when_path_does_not_have_leading_slash_then_path_is_resolved_relative_to_base(): 6 | assert_equal( 7 | "one/two/three/four", 8 | uri_to_zip_entry_name("one/two", "three/four"), 9 | ) 10 | 11 | 12 | def test_when_path_has_leading_slash_then_base_is_ignored(): 13 | assert_equal( 14 | "three/four", 15 | uri_to_zip_entry_name("one/two", "/three/four"), 16 | ) 17 | -------------------------------------------------------------------------------- /mammoth/styles/parser/__init__.py: -------------------------------------------------------------------------------- 1 | from .errors import LineParseError 2 | from .style_mapping_parser import parse_style_mapping 3 | from .tokeniser import tokenise 4 | from .token_iterator import TokenIterator 5 | from ... import results 6 | 7 | 8 | def read_style_mapping(string): 9 | try: 10 | tokens = tokenise(string) 11 | return results.success(parse_style_mapping(TokenIterator(tokens))) 12 | except LineParseError: 13 | warning = "Did not understand this style mapping, so ignored it: " + string 14 | return results.Result(None, [results.warning(warning)]) 15 | -------------------------------------------------------------------------------- /mammoth/styles/parser/style_mapping_parser.py: -------------------------------------------------------------------------------- 1 | from .tokeniser import TokenType 2 | from .document_matcher_parser import parse_document_matcher 3 | from .html_path_parser import parse_html_path 4 | from ...styles import Style 5 | 6 | 7 | def parse_style_mapping(tokens): 8 | document_matcher = parse_document_matcher(tokens) 9 | tokens.skip(TokenType.WHITESPACE) 10 | tokens.skip(TokenType.SYMBOL, "=>") 11 | tokens.try_skip(TokenType.WHITESPACE) 12 | html_path = parse_html_path(tokens) 13 | tokens.skip(TokenType.END) 14 | 15 | return Style(document_matcher, html_path) 16 | -------------------------------------------------------------------------------- /mammoth/docx/complex_fields.py: -------------------------------------------------------------------------------- 1 | class unknown(object): 2 | pass 3 | 4 | 5 | class Begin: 6 | def __init__(self, *, fld_char): 7 | self.fld_char = fld_char 8 | 9 | 10 | def begin(*, fld_char): 11 | return Begin(fld_char=fld_char) 12 | 13 | 14 | class Hyperlink(object): 15 | def __init__(self, kwargs): 16 | self.kwargs = kwargs 17 | 18 | 19 | def hyperlink(kwargs): 20 | return Hyperlink(kwargs=kwargs) 21 | 22 | 23 | class Checkbox: 24 | def __init__(self, *, checked): 25 | self.checked = checked 26 | 27 | 28 | def checkbox(*, checked): 29 | return Checkbox(checked=checked) 30 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-22.04 8 | 9 | strategy: 10 | matrix: 11 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "pypy3.9"] 12 | 13 | steps: 14 | 15 | - uses: actions/checkout@v4 16 | 17 | - name: Use Python ${{ matrix.python-version }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | - run: pip install tox 23 | 24 | - run: make README 25 | 26 | - run: tox -e py 27 | -------------------------------------------------------------------------------- /mammoth/writers/abc.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import abc 4 | 5 | 6 | class Writer(object): 7 | __metaclass__ = abc.ABCMeta 8 | 9 | @abc.abstractmethod 10 | def text(self, text): 11 | pass 12 | 13 | @abc.abstractmethod 14 | def start(self, name, attributes=None): 15 | pass 16 | 17 | @abc.abstractmethod 18 | def end(self, name): 19 | pass 20 | 21 | @abc.abstractmethod 22 | def self_closing(self, name, attributes=None): 23 | pass 24 | 25 | @abc.abstractmethod 26 | def append(self, html): 27 | pass 28 | 29 | @abc.abstractmethod 30 | def as_string(self): 31 | pass 32 | -------------------------------------------------------------------------------- /tests/styles/parser/style_mapping_parser_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth import html_paths, document_matchers, styles 2 | from mammoth.styles.parser.style_mapping_parser import parse_style_mapping 3 | from mammoth.styles.parser.tokeniser import tokenise 4 | from mammoth.styles.parser.token_iterator import TokenIterator 5 | from ...testing import assert_equal 6 | 7 | 8 | def test_document_matcher_is_mapped_to_html_path_using_fat_arrow(): 9 | assert_equal( 10 | styles.style(document_matchers.paragraph(), html_paths.path([html_paths.element(["h1"])])), 11 | read_style_mapping("p => h1") 12 | ) 13 | 14 | 15 | def read_style_mapping(string): 16 | return parse_style_mapping(TokenIterator(tokenise(string))) 17 | -------------------------------------------------------------------------------- /mammoth/docx/document_xml.py: -------------------------------------------------------------------------------- 1 | from .. import documents 2 | 3 | 4 | def read_document_xml_element( 5 | element, 6 | body_reader, 7 | notes=None, 8 | comments=None): 9 | 10 | if notes is None: 11 | notes = [] 12 | if comments is None: 13 | comments = [] 14 | 15 | body_element = element.find_child("w:body") 16 | 17 | if body_element is None: 18 | raise ValueError("Could not find the body element: are you sure this is a docx file?") 19 | 20 | return body_reader.read_all(body_element.children) \ 21 | .map(lambda children: documents.document( 22 | children, 23 | notes=documents.notes(notes), 24 | comments=comments 25 | )) 26 | -------------------------------------------------------------------------------- /mammoth/images.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | from . import html 4 | 5 | 6 | def img_element(func): 7 | def convert_image(image): 8 | attributes = {} 9 | if image.alt_text: 10 | attributes["alt"] = image.alt_text 11 | attributes.update(func(image)) 12 | 13 | return [html.element("img", attributes)] 14 | 15 | return convert_image 16 | 17 | # Undocumented, but retained for backwards-compatibility with 0.3.x 18 | inline = img_element 19 | 20 | 21 | @img_element 22 | def data_uri(image): 23 | with image.open() as image_bytes: 24 | encoded_src = base64.b64encode(image_bytes.read()).decode("ascii") 25 | 26 | return { 27 | "src": "data:{0};base64,{1}".format(image.content_type, encoded_src) 28 | } 29 | -------------------------------------------------------------------------------- /tests/styles/document_matcher_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth import document_matchers 2 | from ..testing import assert_equal 3 | 4 | 5 | def test_equal_to_matcher_is_case_insensitive(): 6 | matcher = document_matchers.equal_to("Heading 1") 7 | assert_equal(True, matcher.matches("heaDING 1")) 8 | assert_equal(False, matcher.matches("heaDING 2")) 9 | 10 | 11 | def test_starts_with_matcher_matches_string_with_prefix(): 12 | matcher = document_matchers.starts_with("Heading") 13 | assert_equal(True, matcher.matches("Heading")) 14 | assert_equal(True, matcher.matches("Heading 1")) 15 | assert_equal(False, matcher.matches("Custom Heading")) 16 | assert_equal(False, matcher.matches("Head")) 17 | assert_equal(False, matcher.matches("Header 2")) 18 | 19 | 20 | def test_starts_with_matcher_is_case_insensitive(): 21 | matcher = document_matchers.starts_with("Heading") 22 | assert_equal(True, matcher.matches("heaDING")) 23 | -------------------------------------------------------------------------------- /mammoth/docx/comments_xml.py: -------------------------------------------------------------------------------- 1 | from .. import lists 2 | from .. import documents 3 | from .. import results 4 | 5 | 6 | def read_comments_xml_element(element, body_reader): 7 | def read_comments_xml_element(element): 8 | comment_elements = element.find_children("w:comment") 9 | return results.combine(lists.map(_read_comment_element, comment_elements)) 10 | 11 | 12 | def _read_comment_element(element): 13 | def read_optional_attribute(name): 14 | return element.attributes.get(name, "").strip() or None 15 | 16 | return body_reader.read_all(element.children).map(lambda body: 17 | documents.comment( 18 | comment_id=element.attributes["w:id"], 19 | body=body, 20 | author_name=read_optional_attribute("w:author"), 21 | author_initials=read_optional_attribute("w:initials"), 22 | )) 23 | 24 | return read_comments_xml_element(element) 25 | -------------------------------------------------------------------------------- /mammoth/lists.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | def flatten(values): 5 | return flat_map(lambda x: x, values) 6 | 7 | 8 | def unique(values): 9 | output = [] 10 | seen = set() 11 | for value in values: 12 | if value not in seen: 13 | seen.add(value) 14 | output.append(value) 15 | return output 16 | 17 | 18 | def flat_map(func, values): 19 | return [ 20 | element 21 | for value in values 22 | for element in func(value) 23 | ] 24 | 25 | 26 | def find_index(predicate, values): 27 | for index, value in enumerate(values): 28 | if predicate(value): 29 | return index 30 | 31 | 32 | if sys.version_info[0] == 2: 33 | map = map 34 | filter = filter 35 | else: 36 | import builtins 37 | def map(*args, **kwargs): 38 | return list(builtins.map(*args, **kwargs)) 39 | def filter(*args, **kwargs): 40 | return list(builtins.filter(*args, **kwargs)) 41 | -------------------------------------------------------------------------------- /tests/docx/document_matchers.py: -------------------------------------------------------------------------------- 1 | from precisely import all_of, has_attrs, instance_of 2 | 3 | from mammoth import documents 4 | 5 | 6 | def create_element_matcher(element_type): 7 | def matcher(**kwargs): 8 | return all_of( 9 | instance_of(element_type), 10 | has_attrs(**kwargs), 11 | ) 12 | 13 | return matcher 14 | 15 | 16 | is_paragraph = create_element_matcher(documents.Paragraph) 17 | is_run = create_element_matcher(documents.Run) 18 | is_hyperlink = create_element_matcher(documents.Hyperlink) 19 | is_checkbox = create_element_matcher(documents.Checkbox) 20 | is_table = create_element_matcher(documents.Table) 21 | is_row = create_element_matcher(documents.TableRow) 22 | is_image = create_element_matcher(documents.Image) 23 | 24 | 25 | is_empty_run = is_run(children=[]) 26 | 27 | 28 | def is_text(value): 29 | return all_of( 30 | instance_of(documents.Text), 31 | has_attrs(value=value), 32 | ) 33 | -------------------------------------------------------------------------------- /mammoth/styles/parser/token_parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from .tokeniser import TokenType 4 | 5 | 6 | def try_parse_class_name(tokens): 7 | if tokens.try_skip(TokenType.SYMBOL, "."): 8 | return parse_identifier(tokens) 9 | else: 10 | return None 11 | 12 | 13 | def parse_identifier(tokens): 14 | return decode_escape_sequences(tokens.next_value(TokenType.IDENTIFIER)) 15 | 16 | 17 | def parse_string(tokens): 18 | return decode_escape_sequences(tokens.next_value(TokenType.STRING)[1:-1]) 19 | 20 | 21 | _ESCAPE_SEQUENCE_REGEX = re.compile(r"\\(.)") 22 | 23 | 24 | def decode_escape_sequences(value): 25 | return _ESCAPE_SEQUENCE_REGEX.sub(_decode_escape_sequence, value) 26 | 27 | 28 | def _decode_escape_sequence(match): 29 | code = match.group(1) 30 | if code == "n": 31 | return "\n" 32 | elif code == "r": 33 | return "\r" 34 | elif code == "t": 35 | return "\t" 36 | else: 37 | return code 38 | -------------------------------------------------------------------------------- /mammoth/results.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | from .lists import unique 4 | 5 | 6 | class Result(object): 7 | def __init__(self, value, messages): 8 | self.value = value 9 | self.messages = unique(messages) 10 | 11 | def map(self, func): 12 | return Result(func(self.value), self.messages) 13 | 14 | def bind(self, func): 15 | result = func(self.value) 16 | return Result(result.value, self.messages + result.messages) 17 | 18 | 19 | Message = collections.namedtuple("Message", ["type", "message"]) 20 | 21 | 22 | def warning(message): 23 | return Message("warning", message) 24 | 25 | 26 | def success(value): 27 | return Result(value, []) 28 | 29 | 30 | def combine(results): 31 | values = [] 32 | messages = [] 33 | for result in results: 34 | values.append(result.value) 35 | for message in result.messages: 36 | messages.append(message) 37 | 38 | return Result(values, messages) 39 | 40 | 41 | def map(func, *args): 42 | return combine(args).map(lambda values: func(*values)) 43 | -------------------------------------------------------------------------------- /mammoth/docx/notes_xml.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from .. import lists 4 | from .. import documents 5 | from .. import results 6 | 7 | 8 | def _read_notes(note_type, element, body_reader): 9 | def read_notes_xml_element(element): 10 | note_elements = lists.filter( 11 | _is_note_element, 12 | element.find_children("w:" + note_type), 13 | ) 14 | return results.combine(lists.map(_read_note_element, note_elements)) 15 | 16 | 17 | def _is_note_element(element): 18 | return element.attributes.get("w:type") not in ["continuationSeparator", "separator"] 19 | 20 | 21 | def _read_note_element(element): 22 | return body_reader.read_all(element.children).map(lambda body: 23 | documents.note( 24 | note_type=note_type, 25 | note_id=element.attributes["w:id"], 26 | body=body 27 | )) 28 | 29 | return read_notes_xml_element(element) 30 | 31 | read_footnotes_xml_element = functools.partial(_read_notes, "footnote") 32 | read_endnotes_xml_element = functools.partial(_read_notes, "endnote") 33 | -------------------------------------------------------------------------------- /tests/zips_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth import zips 2 | from .testing import assert_equal 3 | 4 | 5 | def test_split_path_splits_zip_paths_on_last_forward_slash(): 6 | assert_equal(("a", "b"), zips.split_path("a/b")) 7 | assert_equal(("a/b", "c"), zips.split_path("a/b/c")) 8 | assert_equal(("/a/b", "c"), zips.split_path("/a/b/c")) 9 | 10 | 11 | def test_when_path_has_no_forward_slashes_then_split_path_returns_empty_dirname(): 12 | assert_equal(("", "name"), zips.split_path("name")) 13 | 14 | 15 | def test_join_path_joins_arguments_with_forward_slashes(): 16 | assert_equal("a/b", zips.join_path("a", "b")) 17 | assert_equal("a/b/c", zips.join_path("a/b", "c")) 18 | assert_equal("/a/b/c", zips.join_path("/a/b", "c")) 19 | 20 | 21 | def test_empty_parts_are_ignored_when_joining_paths(): 22 | assert_equal("a", zips.join_path("a", "")) 23 | assert_equal("b", zips.join_path("", "b")) 24 | assert_equal("a/b", zips.join_path("a", "", "b")) 25 | 26 | 27 | def test_when_joining_paths_then_absolute_paths_ignore_earlier_paths(): 28 | assert_equal("/b", zips.join_path("a", "/b")) 29 | assert_equal("/b/c", zips.join_path("a", "/b", "c")) 30 | assert_equal("/b", zips.join_path("/a", "/b")) 31 | assert_equal("/a", zips.join_path("/a")) 32 | -------------------------------------------------------------------------------- /tests/test-data/hyperlinks/word/_rels/document.xml.rels: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | 3 | test: 4 | _virtualenv/bin/pyflakes mammoth tests 5 | sh -c '. _virtualenv/bin/activate; py.test tests' 6 | 7 | .PHONY: test-all 8 | 9 | test-all: 10 | tox 11 | 12 | .PHONY: upload 13 | 14 | upload: setup assert-converted-readme build-dist 15 | _virtualenv/bin/twine upload dist/* 16 | make clean 17 | 18 | .PHONY: build-dist 19 | 20 | build-dist: 21 | rm -rf dist 22 | _virtualenv/bin/pyproject-build 23 | 24 | README: README.md 25 | pandoc --from=markdown --to=rst README.md > README || cp README.md README 26 | 27 | .PHONY: assert-converted-readme 28 | 29 | assert-converted-readme: 30 | test "`cat README`" != "`cat README.md`" 31 | 32 | .PHONY: clean 33 | 34 | clean: 35 | rm -f README 36 | rm -f MANIFEST 37 | rm -rf dist 38 | 39 | .PHONY: bootstrap 40 | 41 | bootstrap: _virtualenv setup 42 | _virtualenv/bin/pip install -e . 43 | ifneq ($(wildcard test-requirements.txt),) 44 | _virtualenv/bin/pip install -r test-requirements.txt 45 | endif 46 | make clean 47 | 48 | .PHONY: setup 49 | 50 | setup: README 51 | 52 | _virtualenv: 53 | python3 -m venv _virtualenv 54 | _virtualenv/bin/pip install --upgrade pip 55 | _virtualenv/bin/pip install --upgrade setuptools 56 | _virtualenv/bin/pip install --upgrade wheel 57 | _virtualenv/bin/pip install --upgrade build twine 58 | -------------------------------------------------------------------------------- /tests/styles/parser/token_parser_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth.styles.parser.tokeniser import Token, TokenType 2 | from mammoth.styles.parser.token_parser import decode_escape_sequences, parse_identifier, parse_string 3 | from mammoth.styles.parser.token_iterator import TokenIterator 4 | from ...testing import assert_equal 5 | 6 | 7 | def test_escape_sequences_in_identifiers_are_decoded(): 8 | assert_equal( 9 | ":", 10 | parse_identifier(TokenIterator([ 11 | Token(0, TokenType.IDENTIFIER, r"\:"), 12 | ])), 13 | ) 14 | 15 | 16 | def test_escape_sequences_in_strings_are_decoded(): 17 | assert_equal( 18 | "\n", 19 | parse_string(TokenIterator([ 20 | Token(0, TokenType.STRING, r"'\n'"), 21 | ])), 22 | ) 23 | 24 | 25 | def test_line_feeds_are_decoded(): 26 | assert_equal("\n", decode_escape_sequences(r"\n")) 27 | 28 | 29 | def test_carriage_returns_are_decoded(): 30 | assert_equal("\r", decode_escape_sequences(r"\r")) 31 | 32 | 33 | def test_tabs_are_decoded(): 34 | assert_equal("\t", decode_escape_sequences(r"\t")) 35 | 36 | 37 | def test_backslashes_are_decoded(): 38 | assert_equal("\\", decode_escape_sequences(r"\\")) 39 | 40 | 41 | def test_colons_are_decoded(): 42 | assert_equal(":", decode_escape_sequences(r"\:")) 43 | -------------------------------------------------------------------------------- /tests/docx/notes_xml_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth import documents 2 | from mammoth.docx.xmlparser import element as xml_element 3 | from mammoth.docx.notes_xml import read_footnotes_xml_element 4 | from mammoth.docx import body_xml 5 | from ..testing import assert_equal 6 | 7 | 8 | def test_id_and_body_of_footnote_are_read(): 9 | footnote_body = [xml_element("w:p")] 10 | footnotes = read_footnotes_xml_element(xml_element("w:footnotes", {}, [ 11 | xml_element("w:footnote", {"w:id": "1"}, footnote_body), 12 | ]), body_reader=body_xml.reader()) 13 | assert_equal(1, len(footnotes.value)) 14 | assert isinstance(footnotes.value[0].body[0], documents.Paragraph) 15 | assert_equal("1", footnotes.value[0].note_id) 16 | 17 | 18 | def test_continuation_separator_is_ignored(): 19 | _assert_footnote_type_is_ignored("continuationSeparator") 20 | 21 | 22 | def test_separator_is_ignored(): 23 | _assert_footnote_type_is_ignored("separator") 24 | 25 | 26 | def _assert_footnote_type_is_ignored(footnote_type): 27 | footnote_body = [xml_element("w:p")] 28 | footnotes = read_footnotes_xml_element(xml_element("w:footnotes", {}, [ 29 | xml_element("w:footnote", {"w:id": "1", "w:type": footnote_type}, footnote_body), 30 | ]), body_reader=None) 31 | assert_equal(0, len(footnotes.value)) 32 | 33 | -------------------------------------------------------------------------------- /mammoth/html/nodes.py: -------------------------------------------------------------------------------- 1 | import cobble 2 | 3 | 4 | class Node(object): 5 | pass 6 | 7 | 8 | @cobble.data 9 | class TextNode(Node): 10 | value = cobble.field() 11 | 12 | 13 | @cobble.data 14 | class Tag(object): 15 | tag_names = cobble.field() 16 | attributes = cobble.field() 17 | collapsible = cobble.field() 18 | separator = cobble.field() 19 | 20 | @property 21 | def tag_name(self): 22 | return self.tag_names[0] 23 | 24 | 25 | @cobble.data 26 | class Element(Node): 27 | tag = cobble.field() 28 | children = cobble.field() 29 | 30 | @property 31 | def tag_name(self): 32 | return self.tag.tag_name 33 | 34 | @property 35 | def tag_names(self): 36 | return self.tag.tag_names 37 | 38 | @property 39 | def attributes(self): 40 | return self.tag.attributes 41 | 42 | @property 43 | def collapsible(self): 44 | return self.tag.collapsible 45 | 46 | @property 47 | def separator(self): 48 | return self.tag.separator 49 | 50 | _VOID_TAG_NAMES = set(["br", "hr", "img", "input"]) 51 | 52 | def is_void(self): 53 | return not self.children and self.tag_name in self._VOID_TAG_NAMES 54 | 55 | 56 | @cobble.visitable 57 | class ForceWrite(Node): 58 | pass 59 | 60 | 61 | NodeVisitor = cobble.visitor(Node) 62 | -------------------------------------------------------------------------------- /tests/options_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth.options import read_options, _default_style_map 2 | from mammoth.styles.parser import read_style_mapping 3 | from .testing import assert_equal 4 | 5 | 6 | def test_default_style_map_is_used_if_style_map_is_not_set(): 7 | assert_equal(_default_style_map, read_options({}).value["style_map"]) 8 | 9 | 10 | def test_custom_style_mappings_are_prepended_to_default_style_mappings(): 11 | style_map = read_options({ 12 | "style_map": "p.SectionTitle => h2" 13 | }).value["style_map"] 14 | assert_equal(read_style_mapping("p.SectionTitle => h2").value, style_map[0]) 15 | assert_equal(_default_style_map, style_map[1:]) 16 | 17 | 18 | def test_default_style_mappings_are_ignored_if_include_default_style_map_is_false(): 19 | style_map = read_options({ 20 | "style_map": "p.SectionTitle => h2", 21 | "include_default_style_map": False 22 | }).value["style_map"] 23 | assert_equal([read_style_mapping("p.SectionTitle => h2").value], style_map) 24 | 25 | 26 | def test_lines_starting_with_hash_in_custom_style_map_are_ignored(): 27 | style_map = read_options({ 28 | "style_map": "#p.SectionTitle => h3\np.SectionTitle => h2", 29 | "include_default_style_map": False 30 | }).value["style_map"] 31 | assert_equal([read_style_mapping("p.SectionTitle => h2").value], style_map) 32 | -------------------------------------------------------------------------------- /mammoth/docx/relationships_xml.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | 4 | class Relationships(object): 5 | def __init__(self, relationships): 6 | self._targets_by_id = dict( 7 | (relationship.relationship_id, relationship.target) 8 | for relationship in relationships 9 | ) 10 | self._targets_by_type = collections.defaultdict(list) 11 | for relationship in relationships: 12 | self._targets_by_type[relationship.type].append(relationship.target) 13 | 14 | def find_target_by_relationship_id(self, key): 15 | return self._targets_by_id[key] 16 | 17 | def find_targets_by_type(self, relationship_type): 18 | return self._targets_by_type[relationship_type] 19 | 20 | 21 | Relationships.EMPTY = Relationships([]) 22 | 23 | 24 | Relationship = collections.namedtuple("Relationship", ["relationship_id", "target", "type"]) 25 | 26 | 27 | def read_relationships_xml_element(element): 28 | children = element.find_children("relationships:Relationship") 29 | return Relationships(list(map(_read_relationship, children))) 30 | 31 | 32 | def _read_relationship(element): 33 | relationship = Relationship( 34 | relationship_id=element.attributes["Id"], 35 | target=element.attributes["Target"], 36 | type=element.attributes["Type"], 37 | ) 38 | return relationship 39 | -------------------------------------------------------------------------------- /mammoth/writers/html.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | from xml.sax.saxutils import escape 3 | 4 | from .abc import Writer 5 | 6 | 7 | class HtmlWriter(Writer): 8 | def __init__(self): 9 | self._fragments = [] 10 | 11 | def text(self, text): 12 | self._fragments.append(_escape_html(text)) 13 | 14 | def start(self, name, attributes=None): 15 | attribute_string = _generate_attribute_string(attributes) 16 | self._fragments.append("<{0}{1}>".format(name, attribute_string)) 17 | 18 | def end(self, name): 19 | self._fragments.append("".format(name)) 20 | 21 | def self_closing(self, name, attributes=None): 22 | attribute_string = _generate_attribute_string(attributes) 23 | self._fragments.append("<{0}{1} />".format(name, attribute_string)) 24 | 25 | def append(self, html): 26 | self._fragments.append(html) 27 | 28 | def as_string(self): 29 | return "".join(self._fragments) 30 | 31 | 32 | def _escape_html(text): 33 | return escape(text, {'"': """}) 34 | 35 | 36 | def _generate_attribute_string(attributes): 37 | if attributes is None: 38 | return "" 39 | else: 40 | return "".join( 41 | ' {0}="{1}"'.format(key, _escape_html(attributes[key])) 42 | for key in sorted(attributes) 43 | ) 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Michael Williamson 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /mammoth/html_paths.py: -------------------------------------------------------------------------------- 1 | import cobble 2 | 3 | from . import html 4 | 5 | 6 | def path(elements): 7 | return HtmlPath(elements) 8 | 9 | 10 | def element(names, attributes=None, class_names=None, fresh=None, separator=None): 11 | if attributes is None: 12 | attributes = {} 13 | if class_names is None: 14 | class_names = [] 15 | if fresh is None: 16 | fresh = False 17 | if class_names: 18 | attributes["class"] = " ".join(class_names) 19 | 20 | return HtmlPathElement(html.tag( 21 | tag_names=names, 22 | attributes=attributes, 23 | collapsible=not fresh, 24 | separator=separator, 25 | )) 26 | 27 | 28 | @cobble.data 29 | class HtmlPath(object): 30 | elements = cobble.field() 31 | 32 | def wrap(self, generate_nodes): 33 | nodes = generate_nodes() 34 | 35 | for element in reversed(self.elements): 36 | nodes = element.wrap_nodes(nodes) 37 | 38 | return nodes 39 | 40 | 41 | @cobble.data 42 | class HtmlPathElement(object): 43 | tag = cobble.field() 44 | 45 | def wrap(self, generate_nodes): 46 | return self.wrap_nodes(generate_nodes()) 47 | 48 | def wrap_nodes(self, nodes): 49 | element = html.Element(self.tag, nodes) 50 | return [element] 51 | 52 | empty = path([]) 53 | 54 | 55 | class ignore(object): 56 | @staticmethod 57 | def wrap(generate_nodes): 58 | return [] 59 | -------------------------------------------------------------------------------- /mammoth/docx/files.py: -------------------------------------------------------------------------------- 1 | import os 2 | import contextlib 3 | try: 4 | from urllib2 import urlopen 5 | except ImportError: 6 | from urllib.request import urlopen 7 | try: 8 | from urllib.parse import urlparse 9 | except ImportError: 10 | from urlparse import urlparse 11 | 12 | 13 | class Files(object): 14 | def __init__(self, base, external_file_access): 15 | self._base = base 16 | self._external_file_access = external_file_access 17 | 18 | def open(self, uri): 19 | if not self._external_file_access: 20 | raise ExternalFileAccessIsDisabledError( 21 | "could not open external image '{0}', external file access is disabled".format(uri) 22 | ) 23 | 24 | try: 25 | if _is_absolute(uri): 26 | return contextlib.closing(urlopen(uri)) 27 | elif self._base is not None: 28 | return open(os.path.join(self._base, uri), "rb") 29 | else: 30 | raise InvalidFileReferenceError("could not find external image '{0}', fileobj has no name".format(uri)) 31 | except IOError as error: 32 | message = "could not open external image: '{0}' (document directory: '{1}')\n{2}".format( 33 | uri, self._base, str(error)) 34 | raise InvalidFileReferenceError(message) 35 | 36 | 37 | def _is_absolute(url): 38 | return urlparse(url).scheme != "" 39 | 40 | 41 | class InvalidFileReferenceError(ValueError): 42 | pass 43 | 44 | 45 | class ExternalFileAccessIsDisabledError(InvalidFileReferenceError): 46 | pass 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | from setuptools import setup 5 | 6 | def read(fname): 7 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 8 | 9 | 10 | setup( 11 | name='mammoth', 12 | version='1.11.0', 13 | description='Convert Word documents from docx to simple and clean HTML and Markdown', 14 | long_description=read("README"), 15 | author='Michael Williamson', 16 | author_email='mike@zwobble.org', 17 | url='https://github.com/mwilliamson/python-mammoth', 18 | packages=['mammoth', 'mammoth.docx', 'mammoth.html', 'mammoth.styles', 'mammoth.styles.parser', 'mammoth.writers'], 19 | entry_points={ 20 | "console_scripts": [ 21 | "mammoth=mammoth.cli:main" 22 | ] 23 | }, 24 | keywords="docx word office clean html markdown md", 25 | install_requires=[ 26 | "cobble>=0.1.3,<0.2", 27 | ], 28 | python_requires='>=3.7', 29 | license="BSD-2-Clause", 30 | classifiers=[ 31 | 'Development Status :: 5 - Production/Stable', 32 | 'Intended Audience :: Developers', 33 | 'License :: OSI Approved :: BSD License', 34 | 'Programming Language :: Python', 35 | 'Programming Language :: Python :: 3', 36 | 'Programming Language :: Python :: 3.7', 37 | 'Programming Language :: Python :: 3.8', 38 | 'Programming Language :: Python :: 3.9', 39 | 'Programming Language :: Python :: 3.10', 40 | 'Programming Language :: Python :: 3.11', 41 | 'Programming Language :: Python :: 3.12', 42 | ], 43 | ) 44 | 45 | -------------------------------------------------------------------------------- /tests/docx/office_xml_tests.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import io 4 | 5 | from mammoth.docx import xmlparser as xml, office_xml 6 | from ..testing import assert_equal 7 | 8 | 9 | class AlternateContentTests(object): 10 | def test_when_fallback_is_present_then_fallback_is_read(self): 11 | xml_string = ( 12 | '' + 13 | '' + 14 | '' + 15 | '' + 16 | '' + 17 | '' + 18 | '' + 19 | '' + 20 | '' + 21 | '' + 22 | '') 23 | 24 | result = office_xml.read(io.StringIO(xml_string)) 25 | assert_equal([xml.element("fallback")], result.children) 26 | 27 | 28 | def test_when_fallback_is_not_present_then_element_is_ignored(self): 29 | xml_string = ( 30 | '' + 31 | '' + 32 | '' + 33 | '' + 34 | '' + 35 | '' + 36 | '' + 37 | '') 38 | 39 | result = office_xml.read(io.StringIO(xml_string)) 40 | assert_equal([], result.children) 41 | -------------------------------------------------------------------------------- /tests/raw_text_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth.raw_text import extract_raw_text_from_element 2 | from mammoth import documents 3 | from .testing import assert_equal 4 | 5 | 6 | def test_text_element_is_converted_to_text_content(): 7 | element = documents.Text("Hello.") 8 | 9 | result = extract_raw_text_from_element(element) 10 | 11 | assert_equal("Hello.", result) 12 | 13 | 14 | def test_tab_element_is_converted_to_tab_character(): 15 | element = documents.tab() 16 | 17 | result = extract_raw_text_from_element(element) 18 | 19 | assert_equal("\t", result) 20 | 21 | 22 | def test_paragraphs_are_terminated_with_newlines(): 23 | element = documents.paragraph( 24 | children=[ 25 | documents.Text("Hello "), 26 | documents.Text("world."), 27 | ], 28 | ) 29 | 30 | result = extract_raw_text_from_element(element) 31 | 32 | assert_equal("Hello world.\n\n", result) 33 | 34 | 35 | def test_children_are_recursively_converted_to_text(): 36 | element = documents.document([ 37 | documents.paragraph( 38 | [ 39 | documents.text("Hello "), 40 | documents.text("world.") 41 | ], 42 | {} 43 | ) 44 | ]) 45 | 46 | result = extract_raw_text_from_element(element) 47 | 48 | assert_equal("Hello world.\n\n", result) 49 | 50 | 51 | def test_non_text_element_without_children_is_converted_to_empty_string(): 52 | element = documents.line_break 53 | assert not hasattr(element, "children") 54 | 55 | result = extract_raw_text_from_element(element) 56 | 57 | assert_equal("", result) 58 | -------------------------------------------------------------------------------- /mammoth/transforms.py: -------------------------------------------------------------------------------- 1 | from . import documents 2 | 3 | 4 | def paragraph(transform_paragraph): 5 | return element_of_type(documents.Paragraph, transform_paragraph) 6 | 7 | 8 | def run(transform_run): 9 | return element_of_type(documents.Run, transform_run) 10 | 11 | 12 | def element_of_type(element_type, transform): 13 | def transform_element(element): 14 | if isinstance(element, element_type): 15 | return transform(element) 16 | else: 17 | return element 18 | 19 | return _each_element(transform_element) 20 | 21 | 22 | def _each_element(transform_element): 23 | def transform_element_and_children(element): 24 | if isinstance(element, (documents.HasChildren, documents.TableCellUnmerged)): 25 | children = list(map(transform_element_and_children, element.children)) 26 | element = element.copy(children=children) 27 | 28 | return transform_element(element) 29 | 30 | return transform_element_and_children 31 | 32 | 33 | def get_descendants_of_type(element, element_type): 34 | return list(filter( 35 | lambda descendant: isinstance(descendant, element_type), 36 | get_descendants(element), 37 | )) 38 | 39 | 40 | def get_descendants(element): 41 | descendants = [] 42 | 43 | def visit(element): 44 | descendants.append(element) 45 | 46 | _visit_descendants(element, visit) 47 | 48 | return descendants 49 | 50 | 51 | def _visit_descendants(element, visit): 52 | if isinstance(element, documents.HasChildren): 53 | for child in element.children: 54 | _visit_descendants(child, visit) 55 | visit(child) 56 | 57 | -------------------------------------------------------------------------------- /tests/test-data/hyperlinks/word/document.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | coconuts 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/html/strip_empty_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth import html 2 | from ..testing import assert_equal 3 | 4 | 5 | def test_text_nodes_with_text_are_not_stripped(): 6 | assert_equal( 7 | [html.text("H")], 8 | html.strip_empty([html.text("H")])) 9 | 10 | 11 | def test_empty_text_nodes_are_stripped(): 12 | assert_equal( 13 | [], 14 | html.strip_empty([html.text("")])) 15 | 16 | 17 | def test_elements_with_non_empty_children_are_not_stripped(): 18 | assert_equal( 19 | [html.element("p", {}, [html.text("H")])], 20 | html.strip_empty([html.element("p", {}, [html.text("H")])])) 21 | 22 | 23 | def test_elements_with_no_children_are_stripped(): 24 | assert_equal( 25 | [], 26 | html.strip_empty([html.element("p")])) 27 | 28 | 29 | def test_elements_with_only_empty_children_are_stripped(): 30 | assert_equal( 31 | [], 32 | html.strip_empty([html.element("p", {}, [html.text("")])])) 33 | 34 | 35 | def test_empty_children_are_removed(): 36 | assert_equal( 37 | html.strip_empty([html.element("ul", {}, [ 38 | html.element("li", {}, [html.text("")]), 39 | html.element("li", {}, [html.text("H")]), 40 | ])]), 41 | 42 | [html.element("ul", {}, [ 43 | html.element("li", {}, [html.text("H")]) 44 | ])]) 45 | 46 | 47 | def test_self_closing_elements_are_never_empty(): 48 | assert_equal( 49 | [html.element("br")], 50 | html.strip_empty([html.element("br")])) 51 | 52 | 53 | def test_force_writes_are_never_empty(): 54 | assert_equal( 55 | [html.force_write], 56 | html.strip_empty([html.force_write])) 57 | -------------------------------------------------------------------------------- /tests/test-data/simple/word/document.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hello. 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /mammoth/__init__.py: -------------------------------------------------------------------------------- 1 | from . import docx, conversion, options, images, transforms, underline 2 | from .raw_text import extract_raw_text_from_element 3 | from .docx.style_map import write_style_map, read_style_map 4 | 5 | __all__ = ["convert_to_html", "extract_raw_text", "images", "transforms", "underline"] 6 | 7 | 8 | _undefined = object() 9 | 10 | 11 | def convert_to_html(*args, **kwargs): 12 | return convert(*args, output_format="html", **kwargs) 13 | 14 | 15 | def convert_to_markdown(*args, **kwargs): 16 | return convert(*args, output_format="markdown", **kwargs) 17 | 18 | 19 | def convert( 20 | fileobj, 21 | transform_document=None, 22 | id_prefix=None, 23 | include_embedded_style_map=_undefined, 24 | external_file_access=_undefined, 25 | **kwargs 26 | ): 27 | if include_embedded_style_map is _undefined: 28 | include_embedded_style_map = True 29 | 30 | if transform_document is None: 31 | transform_document = lambda x: x 32 | 33 | if include_embedded_style_map: 34 | kwargs["embedded_style_map"] = read_style_map(fileobj) 35 | 36 | if external_file_access is _undefined: 37 | external_file_access = False 38 | 39 | return options.read_options(kwargs).bind(lambda convert_options: 40 | docx.read(fileobj, external_file_access=external_file_access).map(transform_document).bind(lambda document: 41 | conversion.convert_document_element_to_html( 42 | document, 43 | id_prefix=id_prefix, 44 | **convert_options 45 | ) 46 | ) 47 | ) 48 | 49 | 50 | def extract_raw_text(fileobj): 51 | return docx.read(fileobj).map(extract_raw_text_from_element) 52 | 53 | 54 | def embed_style_map(fileobj, style_map): 55 | write_style_map(fileobj, style_map) 56 | 57 | def read_embedded_style_map(fileobj): 58 | return read_style_map(fileobj) 59 | -------------------------------------------------------------------------------- /mammoth/docx/content_types_xml.py: -------------------------------------------------------------------------------- 1 | def read_content_types_xml_element(element): 2 | extension_defaults = dict(map( 3 | _read_default, 4 | element.find_children("content-types:Default") 5 | )) 6 | overrides = dict(map( 7 | _read_override, 8 | element.find_children("content-types:Override") 9 | )) 10 | return _ContentTypes(extension_defaults, overrides) 11 | 12 | 13 | def _read_default(element): 14 | extension = element.attributes["Extension"] 15 | content_type = element.attributes["ContentType"] 16 | return extension, content_type 17 | 18 | 19 | def _read_override(element): 20 | part_name = element.attributes["PartName"] 21 | content_type = element.attributes["ContentType"] 22 | return part_name.lstrip("/"), content_type 23 | 24 | 25 | class _ContentTypes(object): 26 | _image_content_types = { 27 | "png": "png", 28 | "gif": "gif", 29 | "jpeg": "jpeg", 30 | "jpg": "jpeg", 31 | "tif": "tiff", 32 | "tiff": "tiff", 33 | "bmp": "bmp", 34 | } 35 | 36 | def __init__(self, extension_defaults, overrides): 37 | self._extension_defaults = extension_defaults 38 | self._overrides = overrides 39 | 40 | def find_content_type(self, path): 41 | if path in self._overrides: 42 | return self._overrides[path] 43 | 44 | extension = _get_extension(path) 45 | default_type = self._extension_defaults.get(extension) 46 | if default_type is not None: 47 | return default_type 48 | 49 | image_type = self._image_content_types.get(extension.lower()) 50 | if image_type is not None: 51 | return "image/" + image_type 52 | 53 | return None 54 | 55 | empty_content_types = _ContentTypes({}, {}) 56 | 57 | def _get_extension(path): 58 | return path.rpartition(".")[2] 59 | -------------------------------------------------------------------------------- /mammoth/styles/parser/tokeniser.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import re 3 | 4 | 5 | Token = collections.namedtuple("Token", ["character_index", "type", "value"]) 6 | 7 | 8 | class TokenType(object): 9 | IDENTIFIER = "identifier" 10 | SYMBOL = "symbol" 11 | WHITESPACE = "whitespace" 12 | STRING = "string" 13 | UNTERMINATED_STRING = "unterminated string" 14 | INTEGER = "integer" 15 | END = "end" 16 | 17 | 18 | 19 | def regex_tokeniser(rules): 20 | rules = [(token_type, _to_regex(regex)) for token_type, regex in rules] 21 | rules.append(("unknown", re.compile("."))) 22 | 23 | def tokenise(value): 24 | tokens = [] 25 | index = 0 26 | while index < len(value): 27 | for token_type, regex in rules: 28 | match = regex.match(value, index) 29 | if match is not None: 30 | tokens.append(Token(index, token_type, match.group(0))) 31 | index = match.end() 32 | break 33 | else: 34 | # Should be impossible 35 | raise Exception("Remaining: " + value[index:]) 36 | 37 | tokens.append(Token(index, TokenType.END, "")) 38 | 39 | return tokens 40 | 41 | return tokenise 42 | 43 | 44 | def _to_regex(value): 45 | if hasattr(value, "match"): 46 | return value 47 | else: 48 | return re.compile(value) 49 | 50 | 51 | _string_prefix = r"'(?:\\.|[^'])*" 52 | _identifier_character = r"(?:[a-zA-Z\-_]|\\.)" 53 | 54 | tokenise = regex_tokeniser([ 55 | (TokenType.IDENTIFIER, _identifier_character + "(?:" + _identifier_character + "|[0-9])*"), 56 | (TokenType.SYMBOL, r":|>|=>|\^=|=|\(|\)|\[|\]|\||!|\."), 57 | (TokenType.WHITESPACE, r"\s+"), 58 | (TokenType.STRING, _string_prefix + "'"), 59 | (TokenType.UNTERMINATED_STRING, _string_prefix), 60 | (TokenType.INTEGER, "([0-9]+)"), 61 | ]) 62 | -------------------------------------------------------------------------------- /tests/docx/comments_xml_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth import documents 2 | from mammoth.docx.xmlparser import element as xml_element 3 | from mammoth.docx.comments_xml import read_comments_xml_element 4 | from mammoth.docx import body_xml 5 | from ..testing import assert_equal 6 | 7 | 8 | def test_id_and_body_of_comment_is_read(): 9 | body = [xml_element("w:p")] 10 | comments = read_comments_xml_element(xml_element("w:comments", {}, [ 11 | xml_element("w:comment", {"w:id": "1"}, body), 12 | ]), body_reader=body_xml.reader()) 13 | assert_equal(1, len(comments.value)) 14 | assert_equal(comments.value[0].body, [documents.paragraph(children=[])]) 15 | assert_equal("1", comments.value[0].comment_id) 16 | 17 | 18 | def test_when_optional_attributes_of_comment_are_missing_then_they_are_read_as_none(): 19 | comments = read_comments_xml_element(xml_element("w:comments", {}, [ 20 | xml_element("w:comment", {"w:id": "1"}, []), 21 | ]), body_reader=body_xml.reader()) 22 | comment, = comments.value 23 | assert_equal(None, comment.author_name) 24 | assert_equal(None, comment.author_initials) 25 | 26 | 27 | def test_when_optional_attributes_of_comment_are_blank_then_they_are_read_as_none(): 28 | comments = read_comments_xml_element(xml_element("w:comments", {}, [ 29 | xml_element("w:comment", {"w:id": "1", "w:author": " ", "w:initials": " "}, []), 30 | ]), body_reader=body_xml.reader()) 31 | comment, = comments.value 32 | assert_equal(None, comment.author_name) 33 | assert_equal(None, comment.author_initials) 34 | 35 | 36 | def test_when_optional_attributes_of_comment_are_not_blank_then_they_are_read(): 37 | comments = read_comments_xml_element(xml_element("w:comments", {}, [ 38 | xml_element("w:comment", {"w:id": "1", "w:author": "The Piemaker", "w:initials": "TP"}, []), 39 | ]), body_reader=body_xml.reader()) 40 | comment, = comments.value 41 | assert_equal("The Piemaker", comment.author_name) 42 | assert_equal("TP", comment.author_initials) 43 | -------------------------------------------------------------------------------- /mammoth/docx/office_xml.py: -------------------------------------------------------------------------------- 1 | from ..lists import flat_map 2 | from .xmlparser import parse_xml, XmlElement 3 | 4 | 5 | _namespaces = [ 6 | # Transitional format 7 | ("w", "http://schemas.openxmlformats.org/wordprocessingml/2006/main"), 8 | ("r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships"), 9 | ("wp", "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"), 10 | ("a", "http://schemas.openxmlformats.org/drawingml/2006/main"), 11 | ("pic", "http://schemas.openxmlformats.org/drawingml/2006/picture"), 12 | 13 | # Strict format 14 | ("w", "http://purl.oclc.org/ooxml/wordprocessingml/main"), 15 | ("r", "http://purl.oclc.org/ooxml/officeDocument/relationships"), 16 | ("wp", "http://purl.oclc.org/ooxml/drawingml/wordprocessingDrawing"), 17 | ("a", "http://purl.oclc.org/ooxml/drawingml/main"), 18 | ("pic", "http://purl.oclc.org/ooxml/drawingml/picture"), 19 | 20 | # Common 21 | ("content-types", "http://schemas.openxmlformats.org/package/2006/content-types"), 22 | ("relationships", "http://schemas.openxmlformats.org/package/2006/relationships"), 23 | ("mc", "http://schemas.openxmlformats.org/markup-compatibility/2006"), 24 | ("v", "urn:schemas-microsoft-com:vml"), 25 | ("office-word", "urn:schemas-microsoft-com:office:word"), 26 | 27 | # [MS-DOCX]: Word Extensions to the Office Open XML (.docx) File Format 28 | # https://learn.microsoft.com/en-us/openspecs/office_standards/ms-docx/b839fe1f-e1ca-4fa6-8c26-5954d0abbccd 29 | ("wordml", "http://schemas.microsoft.com/office/word/2010/wordml"), 30 | ] 31 | 32 | 33 | def read(fileobj): 34 | return _collapse_alternate_content(parse_xml(fileobj, _namespaces))[0] 35 | 36 | 37 | def _collapse_alternate_content(node): 38 | if isinstance(node, XmlElement): 39 | if node.name == "mc:AlternateContent": 40 | return node.find_child_or_null("mc:Fallback").children 41 | else: 42 | node.children = flat_map(_collapse_alternate_content, node.children) 43 | return [node] 44 | else: 45 | return [node] 46 | -------------------------------------------------------------------------------- /mammoth/styles/parser/token_iterator.py: -------------------------------------------------------------------------------- 1 | # TODO: check indices 2 | # TODO: proper tests for unexpected tokens 3 | 4 | from .errors import LineParseError 5 | 6 | 7 | class TokenIterator(object): 8 | def __init__(self, tokens): 9 | self._tokens = tokens 10 | self._index = 0 11 | 12 | def peek_token_type(self): 13 | return self._tokens[self._index].type 14 | 15 | def next_value(self, token_type=None): 16 | return self._next(token_type).value 17 | 18 | def _next(self, token_type=None): 19 | token = self._tokens[self._index] 20 | if token_type is None or token.type == token_type: 21 | self._index += 1 22 | return token 23 | else: 24 | raise self._unexpected_token_type(token_type, token) 25 | 26 | def skip(self, token_type, token_value=None): 27 | token = self._tokens[self._index] 28 | if token.type == token_type and (token_value is None or token.value == token_value): 29 | self._index += 1 30 | return True 31 | else: 32 | raise self._unexpected_token_type(token_type, token) 33 | 34 | def try_skip(self, token_type, token_value=None): 35 | if self.is_next(token_type, token_value): 36 | self._index += 1 37 | return True 38 | else: 39 | return False 40 | 41 | def try_skip_many(self, tokens): 42 | start = self._index 43 | for token_type, token_value in tokens: 44 | token = self._tokens[self._index] 45 | if not (token.type == token_type and (token_value is None or token.value == token_value)): 46 | self._index = start 47 | return False 48 | else: 49 | self._index += 1 50 | 51 | return True 52 | 53 | def is_next(self, token_type, token_value=None): 54 | token = self._tokens[self._index] 55 | return token.type == token_type and (token_value is None or token.value == token_value) 56 | 57 | def _unexpected_token_type(self, token_type, token): 58 | raise LineParseError() 59 | 60 | -------------------------------------------------------------------------------- /mammoth/zips.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import io 3 | import shutil 4 | 5 | from zipfile import ZipFile 6 | 7 | 8 | def open_zip(fileobj, mode): 9 | return _Zip(ZipFile(fileobj, mode)) 10 | 11 | 12 | class _Zip(object): 13 | def __init__(self, zip_file): 14 | self._zip_file = zip_file 15 | 16 | def __enter__(self): 17 | return self 18 | 19 | def __exit__(self, *args): 20 | self._zip_file.close() 21 | 22 | def open(self, name): 23 | return contextlib.closing(self._zip_file.open(name)) 24 | 25 | def exists(self, name): 26 | try: 27 | self._zip_file.getinfo(name) 28 | return True 29 | except KeyError: 30 | return False 31 | 32 | def read_str(self, name): 33 | return self._zip_file.read(name).decode("utf8") 34 | 35 | 36 | def update_zip(fileobj, files): 37 | source = ZipFile(fileobj, "r") 38 | try: 39 | destination_fileobj = io.BytesIO() 40 | destination = ZipFile(destination_fileobj, "w") 41 | try: 42 | names = set(source.namelist()) | set(files.keys()) 43 | for name in names: 44 | if name in files: 45 | contents = files[name] 46 | else: 47 | contents = source.read(name) 48 | destination.writestr(name, contents) 49 | finally: 50 | destination.close() 51 | finally: 52 | source.close() 53 | 54 | fileobj.seek(0) 55 | destination_fileobj.seek(0) 56 | shutil.copyfileobj(destination_fileobj, fileobj) 57 | 58 | 59 | def split_path(path): 60 | parts = path.rsplit("/", 1) 61 | if len(parts) == 1: 62 | return ("", path) 63 | else: 64 | return tuple(parts) 65 | 66 | 67 | def join_path(*args): 68 | non_empty_paths = list(filter(None, args)) 69 | 70 | relevant_paths = [] 71 | for path in non_empty_paths: 72 | if path.startswith("/"): 73 | relevant_paths = [path] 74 | else: 75 | relevant_paths.append(path) 76 | 77 | return "/".join(relevant_paths) 78 | -------------------------------------------------------------------------------- /tests/docx/document_xml_tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from mammoth import documents 4 | from mammoth.docx.xmlparser import element as xml_element, text as xml_text 5 | from mammoth.docx.document_xml import read_document_xml_element 6 | from mammoth.docx import body_xml 7 | from ..testing import assert_equal 8 | 9 | 10 | def test_when_body_element_is_present_then_body_is_read(): 11 | text_xml = xml_element("w:t", {}, [xml_text("Hello!")]) 12 | run_xml = xml_element("w:r", {}, [text_xml]) 13 | paragraph_xml = xml_element("w:p", {}, [run_xml]) 14 | body_xml = xml_element("w:body", {}, [paragraph_xml]) 15 | document_xml = xml_element("w:document", {}, [body_xml]) 16 | 17 | document = _read_and_get_document_xml_element(document_xml) 18 | 19 | assert_equal( 20 | documents.document([documents.paragraph([documents.run([documents.text("Hello!")])])]), 21 | document 22 | ) 23 | 24 | 25 | def test_when_body_element_is_not_present_then_error_is_raised(): 26 | paragraph_xml = xml_element("w:p", {}, []) 27 | body_xml = xml_element("w:body2", {}, [paragraph_xml]) 28 | document_xml = xml_element("w:document", {}, [body_xml]) 29 | 30 | error = pytest.raises(ValueError, lambda: _read_and_get_document_xml_element(document_xml)) 31 | 32 | assert_equal(str(error.value), "Could not find the body element: are you sure this is a docx file?") 33 | 34 | 35 | def test_footnotes_of_document_are_read(): 36 | notes = [documents.note("footnote", "4", [documents.paragraph([])])] 37 | 38 | body_xml = xml_element("w:body") 39 | document_xml = xml_element("w:document", {}, [body_xml]) 40 | 41 | document = _read_and_get_document_xml_element(document_xml, notes=notes) 42 | footnote = document.notes.find_note("footnote", "4") 43 | assert_equal("4", footnote.note_id) 44 | assert isinstance(footnote.body[0], documents.Paragraph) 45 | 46 | 47 | def _read_and_get_document_xml_element(*args, **kwargs): 48 | body_reader = body_xml.reader() 49 | result = read_document_xml_element(*args, body_reader=body_reader, **kwargs) 50 | assert_equal([], result.messages) 51 | return result.value 52 | -------------------------------------------------------------------------------- /mammoth/document_matchers.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | import cobble 4 | 5 | 6 | def paragraph(style_id=None, style_name=None, numbering=None): 7 | return ParagraphMatcher(style_id, style_name, numbering) 8 | 9 | 10 | ParagraphMatcher = collections.namedtuple("ParagraphMatcher", ["style_id", "style_name", "numbering"]) 11 | ParagraphMatcher.element_type = "paragraph" 12 | 13 | 14 | def run(style_id=None, style_name=None): 15 | return RunMatcher(style_id, style_name) 16 | 17 | 18 | RunMatcher = collections.namedtuple("RunMatcher", ["style_id", "style_name"]) 19 | RunMatcher.element_type = "run" 20 | 21 | 22 | def table(style_id=None, style_name=None): 23 | return TableMatcher(style_id, style_name) 24 | 25 | 26 | TableMatcher = collections.namedtuple("TableMatcher", ["style_id", "style_name"]) 27 | TableMatcher.element_type = "table" 28 | 29 | 30 | class bold(object): 31 | element_type = "bold" 32 | 33 | 34 | class italic(object): 35 | element_type = "italic" 36 | 37 | 38 | class underline(object): 39 | element_type = "underline" 40 | 41 | 42 | class strikethrough(object): 43 | element_type = "strikethrough" 44 | 45 | 46 | class all_caps(object): 47 | element_type = "all_caps" 48 | 49 | 50 | class small_caps(object): 51 | element_type = "small_caps" 52 | 53 | 54 | def highlight(color=None): 55 | return HighlightMatcher(color=color) 56 | 57 | 58 | HighlightMatcher = collections.namedtuple("HighlightMatcher", ["color"]) 59 | HighlightMatcher.element_type = "highlight" 60 | 61 | class comment_reference(object): 62 | element_type = "comment_reference" 63 | 64 | 65 | BreakMatcher = collections.namedtuple("BreakMatcher", ["break_type"]) 66 | BreakMatcher.element_type = "break" 67 | 68 | 69 | line_break = BreakMatcher("line") 70 | page_break = BreakMatcher("page") 71 | column_break = BreakMatcher("column") 72 | 73 | 74 | def equal_to(value): 75 | return StringMatcher(_operator_equal_to, value) 76 | 77 | 78 | def _operator_equal_to(first, second): 79 | return first.upper() == second.upper() 80 | 81 | 82 | def starts_with(value): 83 | return StringMatcher(_operator_starts_with, value) 84 | 85 | def _operator_starts_with(first, second): 86 | return second.upper().startswith(first.upper()) 87 | 88 | 89 | @cobble.data 90 | class StringMatcher(object): 91 | operator = cobble.field() 92 | value = cobble.field() 93 | 94 | def matches(self, other): 95 | return self.operator(self.value, other) 96 | -------------------------------------------------------------------------------- /tests/images_tests.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from precisely import assert_that, has_attrs, is_sequence 4 | 5 | import mammoth 6 | 7 | 8 | def test_inline_is_available_as_alias_of_img_element(): 9 | assert mammoth.images.inline is mammoth.images.img_element 10 | 11 | 12 | def test_data_uri_encodes_images_in_base64(): 13 | image_bytes = b"abc" 14 | image = mammoth.documents.Image( 15 | alt_text=None, 16 | content_type="image/jpeg", 17 | open=lambda: io.BytesIO(image_bytes), 18 | ) 19 | 20 | result = mammoth.images.data_uri(image) 21 | 22 | assert_that(result, is_sequence( 23 | has_attrs(attributes={"src": "data:image/jpeg;base64,YWJj"}), 24 | )) 25 | 26 | 27 | class ImgElementTests: 28 | def test_when_element_does_not_have_alt_text_then_alt_attribute_is_not_set(self): 29 | image_bytes = b"abc" 30 | image = mammoth.documents.Image( 31 | alt_text=None, 32 | content_type="image/jpeg", 33 | open=lambda: io.BytesIO(image_bytes), 34 | ) 35 | 36 | @mammoth.images.img_element 37 | def convert_image(image): 38 | return {"src": ""} 39 | 40 | result = convert_image(image) 41 | 42 | assert_that(result, is_sequence( 43 | has_attrs(attributes={"src": ""}), 44 | )) 45 | 46 | def test_when_element_se_alt_text_then_alt_attribute_is_set(self): 47 | image_bytes = b"abc" 48 | image = mammoth.documents.Image( 49 | alt_text="", 50 | content_type="image/jpeg", 51 | open=lambda: io.BytesIO(image_bytes), 52 | ) 53 | 54 | @mammoth.images.img_element 55 | def convert_image(image): 56 | return {"src": ""} 57 | 58 | result = convert_image(image) 59 | 60 | assert_that(result, is_sequence( 61 | has_attrs(attributes={"alt": "", "src": ""}), 62 | )) 63 | 64 | def test_image_alt_text_can_be_overridden_by_alt_attribute_returned_from_function(self): 65 | image_bytes = b"abc" 66 | image = mammoth.documents.Image( 67 | alt_text="", 68 | content_type="image/jpeg", 69 | open=lambda: io.BytesIO(image_bytes), 70 | ) 71 | 72 | @mammoth.images.img_element 73 | def convert_image(image): 74 | return {"alt": "", "src": ""} 75 | 76 | result = convert_image(image) 77 | 78 | assert_that(result, is_sequence( 79 | has_attrs(attributes={"alt": "", "src": ""}), 80 | )) 81 | -------------------------------------------------------------------------------- /tests/docx/relationships_xml_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth.docx.xmlparser import element as xml_element 2 | from mammoth.docx.relationships_xml import read_relationships_xml_element 3 | from ..testing import assert_equal 4 | 5 | 6 | def test_relationship_targets_can_be_found_by_id(): 7 | element = xml_element("relationships:Relationships", {}, [ 8 | xml_element("relationships:Relationship", { 9 | "Id": "rId8", 10 | "Type": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", 11 | "Target": "http://example.com", 12 | }), 13 | xml_element("relationships:Relationship", { 14 | "Id": "rId2", 15 | "Type": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", 16 | "Target": "http://example.net", 17 | }), 18 | ]) 19 | relationships = read_relationships_xml_element(element) 20 | assert_equal( 21 | "http://example.com", 22 | relationships.find_target_by_relationship_id("rId8"), 23 | ) 24 | 25 | 26 | def test_relationship_targets_can_be_found_by_type(): 27 | element = xml_element("relationships:Relationships", {}, [ 28 | xml_element("relationships:Relationship", { 29 | "Id": "rId2", 30 | "Target": "docProps/core.xml", 31 | "Type": "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties", 32 | }), 33 | xml_element("relationships:Relationship", { 34 | "Id": "rId1", 35 | "Target": "word/document.xml", 36 | "Type": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", 37 | }), 38 | xml_element("relationships:Relationship", { 39 | "Id": "rId3", 40 | "Target": "word/document2.xml", 41 | "Type": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", 42 | }), 43 | ]) 44 | relationships = read_relationships_xml_element(element) 45 | assert_equal( 46 | ["word/document.xml", "word/document2.xml"], 47 | relationships.find_targets_by_type("http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"), 48 | ) 49 | 50 | 51 | def test_when_there_are_no_relationships_of_requested_type_then_empty_list_is_returned(): 52 | element = xml_element("relationships:Relationships", {}, []) 53 | relationships = read_relationships_xml_element(element) 54 | assert_equal( 55 | [], 56 | relationships.find_targets_by_type("http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"), 57 | ) 58 | -------------------------------------------------------------------------------- /tests/docx/content_types_xml_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth.docx.xmlparser import element as xml_element 2 | from mammoth.docx.content_types_xml import read_content_types_xml_element 3 | from ..testing import assert_equal 4 | 5 | 6 | def test_content_type_is_based_on_default_for_extension_if_there_is_no_override(): 7 | element = xml_element("content-types:Types", {}, [ 8 | xml_element("content-types:Default", { 9 | "Extension": "png", 10 | "ContentType": "image/png", 11 | }) 12 | ]) 13 | content_types = read_content_types_xml_element(element) 14 | assert_equal( 15 | "image/png", 16 | content_types.find_content_type("word/media/hat.png"), 17 | ) 18 | 19 | 20 | def test_content_type_is_based_on_override_if_present(): 21 | element = xml_element("content-types:Types", {}, [ 22 | xml_element("content-types:Default", { 23 | "Extension": "png", 24 | "ContentType": "image/png", 25 | }), 26 | xml_element("content-types:Override", { 27 | "PartName": "/word/media/hat.png", 28 | "ContentType": "image/hat" 29 | }), 30 | ]) 31 | content_types = read_content_types_xml_element(element) 32 | assert_equal( 33 | "image/hat", 34 | content_types.find_content_type("word/media/hat.png"), 35 | ) 36 | 37 | 38 | def test_fallback_content_types_have_common_image_types(): 39 | element = xml_element("content-types:Types", {}, []) 40 | content_types = read_content_types_xml_element(element) 41 | assert_equal( 42 | "image/png", 43 | content_types.find_content_type("word/media/hat.png"), 44 | ) 45 | assert_equal( 46 | "image/gif", 47 | content_types.find_content_type("word/media/hat.gif"), 48 | ) 49 | assert_equal( 50 | "image/jpeg", 51 | content_types.find_content_type("word/media/hat.jpg"), 52 | ) 53 | assert_equal( 54 | "image/jpeg", 55 | content_types.find_content_type("word/media/hat.jpeg"), 56 | ) 57 | assert_equal( 58 | "image/bmp", 59 | content_types.find_content_type("word/media/hat.bmp"), 60 | ) 61 | assert_equal( 62 | "image/tiff", 63 | content_types.find_content_type("word/media/hat.tif"), 64 | ) 65 | assert_equal( 66 | "image/tiff", 67 | content_types.find_content_type("word/media/hat.tiff"), 68 | ) 69 | 70 | 71 | def test_fallback_content_types_are_case_insensitive(): 72 | element = xml_element("content-types:Types", {}, []) 73 | content_types = read_content_types_xml_element(element) 74 | assert_equal( 75 | "image/png", 76 | content_types.find_content_type("word/media/hat.PnG"), 77 | ) 78 | -------------------------------------------------------------------------------- /tests/styles/parser/html_path_parser_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth import html_paths 2 | from mammoth.styles.parser.html_path_parser import parse_html_path 3 | from mammoth.styles.parser.tokeniser import tokenise 4 | from mammoth.styles.parser.token_iterator import TokenIterator 5 | from ...testing import assert_equal 6 | 7 | 8 | def test_can_read_empty_path(): 9 | assert_equal( 10 | html_paths.empty, 11 | read_html_path("") 12 | ) 13 | 14 | def test_can_read_single_element(): 15 | assert_equal( 16 | html_paths.path([html_paths.element(["p"])]), 17 | read_html_path("p") 18 | ) 19 | 20 | 21 | def test_can_read_choice_of_two_elements(): 22 | assert_equal( 23 | html_paths.path([html_paths.element(["ul", "ol"])]), 24 | read_html_path("ul|ol") 25 | ) 26 | 27 | 28 | def test_can_read_choice_of_three_elements(): 29 | assert_equal( 30 | html_paths.path([html_paths.element(["ul", "ol", "p"])]), 31 | read_html_path("ul|ol|p") 32 | ) 33 | 34 | 35 | def test_can_read_nested_elements(): 36 | assert_equal( 37 | html_paths.path([html_paths.element(["ul"]), html_paths.element(["li"])]), 38 | read_html_path("ul > li") 39 | ) 40 | 41 | 42 | def test_can_read_class_on_element(): 43 | assert_equal( 44 | html_paths.path([html_paths.element(["p"], class_names=["tip"])]), 45 | read_html_path("p.tip") 46 | ) 47 | 48 | 49 | def test_can_read_multiple_classes_on_element(): 50 | assert_equal( 51 | html_paths.path([html_paths.element(["p"], class_names=["tip", "help"])]), 52 | read_html_path("p.tip.help") 53 | ) 54 | 55 | 56 | def test_can_read_attribute_on_element(): 57 | assert_equal( 58 | html_paths.path([html_paths.element(["p"], attributes={"lang": "fr"})]), 59 | read_html_path("p[lang='fr']") 60 | ) 61 | 62 | 63 | def test_can_read_multiple_attributes_on_element(): 64 | assert_equal( 65 | html_paths.path([html_paths.element(["p"], attributes={"lang": "fr", "data-x": "y"})]), 66 | read_html_path("p[lang='fr'][data-x='y']") 67 | ) 68 | 69 | 70 | def test_can_read_when_element_must_be_fresh(): 71 | assert_equal( 72 | html_paths.path([html_paths.element(["p"], fresh=True)]), 73 | read_html_path("p:fresh") 74 | ) 75 | 76 | 77 | def test_can_read_separator_for_elements(): 78 | assert_equal( 79 | html_paths.path([html_paths.element(["p"], separator="x")]), 80 | read_html_path("p:separator('x')") 81 | ) 82 | 83 | 84 | def test_can_read_ignore_element(): 85 | assert_equal( 86 | html_paths.ignore, 87 | read_html_path("!") 88 | ) 89 | 90 | def read_html_path(string): 91 | return parse_html_path(TokenIterator(tokenise(string))) 92 | -------------------------------------------------------------------------------- /tests/docx/files_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth.docx.files import ExternalFileAccessIsDisabledError, Files, InvalidFileReferenceError 2 | from ..testing import generate_test_path, assert_equal, assert_raises 3 | 4 | 5 | def test_when_external_file_access_is_disabled_then_opening_file_raises_error(): 6 | files = Files(None, external_file_access=False) 7 | error = assert_raises(ExternalFileAccessIsDisabledError, lambda: files.open("/tmp/image.png")) 8 | expected_message = ( 9 | "could not open external image '/tmp/image.png', external file access is disabled" 10 | ) 11 | assert_equal(expected_message, str(error)) 12 | 13 | 14 | def test_can_open_files_with_file_uri(): 15 | path = generate_test_path("tiny-picture.png") 16 | files = Files(None, external_file_access=True) 17 | with files.open("file:///" + path) as image_file: 18 | contents = image_file.read() 19 | assert_equal(bytes, type(contents)) 20 | with open(path, "rb") as source_file: 21 | assert_equal(source_file.read(), contents) 22 | 23 | 24 | def test_can_open_files_with_relative_uri(): 25 | files = Files(generate_test_path(""), external_file_access=True) 26 | with files.open("tiny-picture.png") as image_file: 27 | contents = image_file.read() 28 | assert_equal(bytes, type(contents)) 29 | with open(generate_test_path("tiny-picture.png"), "rb") as source_file: 30 | assert_equal(source_file.read(), contents) 31 | 32 | 33 | def test_given_base_is_not_set_when_opening_relative_uri_then_error_is_raised(): 34 | files = Files(None, external_file_access=True) 35 | error = assert_raises(InvalidFileReferenceError, lambda: files.open("not-a-real-file.png")) 36 | expected_message = ( 37 | "could not find external image 'not-a-real-file.png', fileobj has no name" 38 | ) 39 | assert_equal(expected_message, str(error)) 40 | 41 | 42 | def test_error_is_raised_if_relative_uri_cannot_be_opened(): 43 | files = Files("/tmp", external_file_access=True) 44 | error = assert_raises(InvalidFileReferenceError, lambda: files.open("not-a-real-file.png")) 45 | expected_message = ( 46 | "could not open external image: 'not-a-real-file.png' (document directory: '/tmp')\n" + 47 | "[Errno 2] No such file or directory: '/tmp/not-a-real-file.png'" 48 | ) 49 | assert_equal(expected_message, str(error)) 50 | 51 | 52 | def test_error_is_raised_if_file_uri_cannot_be_opened(): 53 | files = Files("/tmp", external_file_access=True) 54 | error = assert_raises(InvalidFileReferenceError, lambda: files.open("file:///not-a-real-file.png")) 55 | expected_message = "could not open external image: 'file:///not-a-real-file.png' (document directory: '/tmp')\n" 56 | assert str(error).startswith(expected_message) 57 | -------------------------------------------------------------------------------- /mammoth/docx/style_map.py: -------------------------------------------------------------------------------- 1 | from xml.etree import ElementTree 2 | 3 | from ..zips import open_zip, update_zip 4 | 5 | 6 | _style_map_path = "mammoth/style-map" 7 | _style_map_absolute_path = "/" + _style_map_path 8 | _relationships_path = "word/_rels/document.xml.rels" 9 | _content_types_path = "[Content_Types].xml" 10 | 11 | 12 | def write_style_map(fileobj, style_map): 13 | with open_zip(fileobj, "r") as zip_file: 14 | relationships_xml = _generate_relationships_xml(zip_file.read_str(_relationships_path)) 15 | content_types_xml = _generate_content_types_xml(zip_file.read_str(_content_types_path)) 16 | 17 | update_zip(fileobj, { 18 | _style_map_path: style_map.encode("utf8"), 19 | _relationships_path: relationships_xml, 20 | _content_types_path: content_types_xml, 21 | }) 22 | 23 | def _generate_relationships_xml(relationships_xml): 24 | schema = "http://schemas.zwobble.org/mammoth/style-map" 25 | relationships_uri = "http://schemas.openxmlformats.org/package/2006/relationships" 26 | relationship_element_name = "{" + relationships_uri + "}Relationship" 27 | 28 | relationships = ElementTree.fromstring(relationships_xml) 29 | _add_or_update_element(relationships, relationship_element_name, "Id", { 30 | "Id": "rMammothStyleMap", 31 | "Type": schema, 32 | "Target": _style_map_absolute_path, 33 | }) 34 | 35 | return ElementTree.tostring(relationships, "UTF-8") 36 | 37 | 38 | def _generate_content_types_xml(content_types_xml): 39 | content_types_uri = "http://schemas.openxmlformats.org/package/2006/content-types" 40 | override_name = "{" + content_types_uri + "}Override" 41 | 42 | types = ElementTree.fromstring(content_types_xml) 43 | _add_or_update_element(types, override_name, "PartName", { 44 | "PartName": _style_map_absolute_path, 45 | "ContentType": "text/prs.mammoth.style-map", 46 | }) 47 | 48 | return ElementTree.tostring(types, "UTF-8") 49 | 50 | 51 | def _add_or_update_element(parent, name, identifying_attribute, attributes): 52 | existing_child = _find_child(parent, name, identifying_attribute, attributes) 53 | if existing_child is None: 54 | ElementTree.SubElement(parent, name, attributes) 55 | else: 56 | existing_child.attrib = attributes 57 | 58 | 59 | def _find_child(parent, name, identifying_attribute, attributes): 60 | for element in parent.iter(): 61 | if element.tag == name and element.get(identifying_attribute) == attributes.get(identifying_attribute): 62 | return element 63 | 64 | 65 | def read_style_map(fileobj): 66 | with open_zip(fileobj, "r") as zip_file: 67 | if zip_file.exists(_style_map_path): 68 | return zip_file.read_str(_style_map_path) 69 | 70 | 71 | -------------------------------------------------------------------------------- /tests/docx/xmlparser_tests.py: -------------------------------------------------------------------------------- 1 | import io 2 | 3 | from mammoth.docx.xmlparser import parse_xml, element as xml_element, text as xml_text 4 | from ..testing import assert_equal 5 | 6 | 7 | def test_can_parse_self_closing_element(): 8 | xml = _parse_xml_string(b"") 9 | assert_equal(xml_element("body", {}, []), xml) 10 | 11 | 12 | def test_can_parse_empty_element_with_separate_closing_tag(): 13 | xml = _parse_xml_string(b"") 14 | assert_equal(xml_element("body", {}, []), xml) 15 | 16 | 17 | def test_can_parse_attributes_of_tag(): 18 | xml = _parse_xml_string(b"") 19 | assert_equal(xml_element("body", {"name": "bob"}, []), xml) 20 | 21 | 22 | def test_can_parse_text_element(): 23 | xml = _parse_xml_string(b"Hello!") 24 | assert_equal(xml_element("body", {}, [xml_text("Hello!")]), xml) 25 | 26 | 27 | def test_can_parse_text_element_before_new_tag(): 28 | xml = _parse_xml_string(b"Hello!
") 29 | assert_equal(xml_element("body", {}, [xml_text("Hello!"), xml_element("br", {}, [])]), xml) 30 | 31 | 32 | def test_can_parse_element_with_children(): 33 | xml = _parse_xml_string(b"") 34 | assert_equal([xml_element("a", {}, []), xml_element("b", {}, [])], xml.children) 35 | 36 | 37 | def test_unmapped_namespaces_uris_are_included_in_braces_as_prefix(): 38 | xml = _parse_xml_string(b'') 39 | assert_equal("{word}body", xml.name) 40 | 41 | 42 | def test_mapped_namespaces_uris_are_translated_using_namespace_map(): 43 | xml = _parse_xml_string(b'', [("x", "word")]) 44 | assert_equal("x:body", xml.name) 45 | 46 | 47 | def test_namespace_of_attributes_is_mapped_to_prefix(): 48 | xml = _parse_xml_string(b'', [("x", "word")]) 49 | assert_equal("Hello!", xml.attributes["x:val"]) 50 | 51 | 52 | def test_whitespace_between_xml_declaration_and_root_tag_is_ignored(): 53 | xml = _parse_xml_string(b'\n') 54 | assert_equal("body", xml.name) 55 | 56 | 57 | class FindChildTests(object): 58 | def test_returns_none_if_no_children(self): 59 | xml = xml_element("a") 60 | assert_equal(None, xml.find_child("b")) 61 | 62 | def test_returns_none_if_no_matching_children(self): 63 | xml = xml_element("a", {}, [xml_element("c")]) 64 | assert_equal(None, xml.find_child("b")) 65 | 66 | def test_returns_first_matching_child(self): 67 | xml = xml_element("a", {}, [xml_element("b", {"id": 1}), xml_element("b", {"id": 2})]) 68 | assert_equal(1, xml.find_child("b").attributes["id"]) 69 | 70 | def test_ignores_text_nodes(self): 71 | xml = xml_element("a", {}, [xml_text("Hello!")]) 72 | assert_equal(None, xml.find_child("b")) 73 | 74 | 75 | def _parse_xml_string(string, namespace_mapping=None): 76 | return parse_xml(io.BytesIO(string), namespace_mapping) 77 | -------------------------------------------------------------------------------- /recipes/wmf_images.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import shutil 4 | import subprocess 5 | import tempfile 6 | 7 | 8 | # An example of how to use LibreOffice and ImageMagick to convert WMF images to 9 | # PNGs. 10 | # 11 | # libreoffice_wmf_conversion uses LibreOffice to convert the image to a PNG. 12 | # This normally creates an image with a large amount of padding, so 13 | # imagemagick_trim can be used to trim the image. 14 | # 15 | # The image can be then be converted using a normal image handler, such as 16 | # mammoth.images.data_uri. 17 | # 18 | # Example usage: 19 | # 20 | # def convert_image(image): 21 | # image = libreoffice_wmf_conversion(image, post_process=imagemagick_trim) 22 | # return mammoth.images.data_uri(image) 23 | # 24 | # with open("document.docx", "rb") as fileobj: 25 | # result = mammoth.convert_to_html(fileobj, convert_image=convert_image) 26 | 27 | 28 | _wmf_extensions = { 29 | "image/x-wmf": ".wmf", 30 | "image/x-emf": ".emf", 31 | } 32 | 33 | 34 | def libreoffice_wmf_conversion(image, post_process=None): 35 | if post_process is None: 36 | post_process = lambda x: x 37 | 38 | wmf_extension = _wmf_extensions.get(image.content_type) 39 | if wmf_extension is None: 40 | return image 41 | else: 42 | temporary_directory = tempfile.mkdtemp() 43 | try: 44 | input_path = os.path.join(temporary_directory, "image" + wmf_extension) 45 | with io.open(input_path, "wb") as input_fileobj: 46 | with image.open() as image_fileobj: 47 | shutil.copyfileobj(image_fileobj, input_fileobj) 48 | 49 | output_path = os.path.join(temporary_directory, "image.png") 50 | subprocess.check_call([ 51 | "libreoffice", 52 | "--headless", 53 | "--convert-to", 54 | "png", 55 | input_path, 56 | "--outdir", 57 | temporary_directory, 58 | ]) 59 | 60 | with io.open(output_path, "rb") as output_fileobj: 61 | output = output_fileobj.read() 62 | 63 | def open_image(): 64 | return io.BytesIO(output) 65 | 66 | return post_process(image.copy( 67 | content_type="image/png", 68 | open=open_image, 69 | )) 70 | finally: 71 | shutil.rmtree(temporary_directory) 72 | 73 | 74 | def imagemagick_trim(image): 75 | command = ["convert", "-", "-trim", "-"] 76 | process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE) 77 | try: 78 | with image.open() as image_fileobj: 79 | shutil.copyfileobj(image_fileobj, process.stdin) 80 | output, err_output = process.communicate() 81 | except: 82 | process.kill() 83 | process.wait() 84 | raise 85 | 86 | return_code = process.poll() 87 | if return_code: 88 | raise subprocess.CalledProcessError(return_code, command) 89 | else: 90 | def open_image(): 91 | return io.BytesIO(output) 92 | 93 | return image.copy(open=open_image) 94 | 95 | -------------------------------------------------------------------------------- /tests/cli_tests.py: -------------------------------------------------------------------------------- 1 | import os 2 | import base64 3 | 4 | import spur 5 | import tempman 6 | 7 | from .testing import assert_equal, generate_test_path 8 | 9 | 10 | _local = spur.LocalShell() 11 | 12 | 13 | def test_html_is_printed_to_stdout_if_output_file_is_not_set(): 14 | docx_path = generate_test_path("single-paragraph.docx") 15 | result = _local.run(["mammoth", docx_path]) 16 | assert_equal(b"", result.stderr_output) 17 | assert_equal(b"

Walking on imported air

", result.output) 18 | 19 | 20 | def test_html_is_written_to_file_if_output_file_is_set(): 21 | with tempman.create_temp_dir() as temp_dir: 22 | output_path = os.path.join(temp_dir.path, "output.html") 23 | docx_path = generate_test_path("single-paragraph.docx") 24 | result = _local.run(["mammoth", docx_path, output_path]) 25 | assert_equal(b"", result.stderr_output) 26 | assert_equal(b"", result.output) 27 | with open(output_path) as output_file: 28 | assert_equal("

Walking on imported air

", output_file.read()) 29 | 30 | 31 | _image_base_64 = b"iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAIAAAACUFjqAAAAAXNSR0IArs4c6QAAAAlwSFlzAAAOvgAADr4B6kKxwAAAABNJREFUKFNj/M+ADzDhlWUYqdIAQSwBE8U+X40AAAAASUVORK5CYII=" 32 | 33 | 34 | def test_inline_images_are_included_in_output_if_writing_to_single_file(): 35 | docx_path = generate_test_path("tiny-picture.docx") 36 | result = _local.run(["mammoth", docx_path]) 37 | assert_equal(b"""

""", result.output) 38 | 39 | 40 | def test_images_are_written_to_separate_files_if_output_dir_is_set(): 41 | with tempman.create_temp_dir() as temp_dir: 42 | output_path = os.path.join(temp_dir.path, "tiny-picture.html") 43 | image_path = os.path.join(temp_dir.path, "1.png") 44 | 45 | docx_path = generate_test_path("tiny-picture.docx") 46 | result = _local.run(["mammoth", docx_path, "--output-dir", temp_dir.path]) 47 | assert_equal(b"", result.stderr_output) 48 | assert_equal(b"", result.output) 49 | with open(output_path) as output_file: 50 | assert_equal("""

""", output_file.read()) 51 | 52 | with open(image_path, "rb") as image_file: 53 | assert_equal(_image_base_64, base64.b64encode(image_file.read())) 54 | 55 | 56 | def test_style_map_is_used_if_set(): 57 | with tempman.create_temp_dir() as temp_dir: 58 | docx_path = generate_test_path("single-paragraph.docx") 59 | style_map_path = os.path.join(temp_dir.path, "style-map") 60 | with open(style_map_path, "w") as style_map_file: 61 | style_map_file.write("p => span:fresh") 62 | result = _local.run(["mammoth", docx_path, "--style-map", style_map_path]) 63 | assert_equal(b"", result.stderr_output) 64 | assert_equal(b"Walking on imported air", result.output) 65 | 66 | 67 | def test_output_format_markdown_option_generates_markdown_output(): 68 | docx_path = generate_test_path("single-paragraph.docx") 69 | result = _local.run(["mammoth", docx_path, "--output-format=markdown"]) 70 | assert_equal(b"", result.stderr_output) 71 | assert_equal(b"Walking on imported air\n\n", result.output) 72 | -------------------------------------------------------------------------------- /tests/transforms_tests.py: -------------------------------------------------------------------------------- 1 | import cobble 2 | 3 | from mammoth import documents, transforms 4 | from mammoth.transforms import get_descendants, get_descendants_of_type, _each_element 5 | from .testing import assert_equal 6 | 7 | 8 | class ParagraphTests(object): 9 | def test_paragraph_is_transformed(self): 10 | paragraph = documents.paragraph(children=[]) 11 | result = transforms.paragraph(lambda _: documents.tab())(paragraph) 12 | assert_equal(documents.tab(), result) 13 | 14 | def test_non_paragraph_elements_are_not_transformed(self): 15 | run = documents.run(children=[]) 16 | result = transforms.paragraph(lambda _: documents.tab())(run) 17 | assert_equal(documents.run(children=[]), result) 18 | 19 | 20 | class RunTests(object): 21 | def test_run_is_transformed(self): 22 | run = documents.run(children=[]) 23 | result = transforms.run(lambda _: documents.tab())(run) 24 | assert_equal(documents.tab(), result) 25 | 26 | def test_non_paragraph_elements_are_not_transformed(self): 27 | paragraph = documents.paragraph(children=[]) 28 | result = transforms.run(lambda _: documents.tab())(paragraph) 29 | assert_equal(documents.paragraph(children=[]), result) 30 | 31 | 32 | class EachElementTests(object): 33 | def test_all_descendants_are_transformed(self): 34 | @cobble.data 35 | class Count(documents.HasChildren): 36 | count = cobble.field() 37 | 38 | root = Count(count=None, children=[ 39 | Count(count=None, children=[ 40 | Count(count=None, children=[]), 41 | ]), 42 | ]) 43 | 44 | current_count = [0] 45 | def set_count(node): 46 | current_count[0] += 1 47 | return node.copy(count=current_count[0]) 48 | 49 | result = _each_element(set_count)(root) 50 | 51 | assert_equal(Count(count=3, children=[ 52 | Count(count=2, children=[ 53 | Count(count=1, children=[]), 54 | ]), 55 | ]), result) 56 | 57 | 58 | class GetDescendantsTests(object): 59 | def test_returns_nothing_if_element_type_has_no_children(self): 60 | assert_equal([], get_descendants(documents.tab())) 61 | 62 | def test_returns_nothing_if_element_has_empty_children(self): 63 | assert_equal([], get_descendants(documents.paragraph(children=[]))) 64 | 65 | def test_includes_children(self): 66 | children = [documents.text("child 1"), documents.text("child 2")] 67 | element = documents.paragraph(children=children) 68 | assert_equal(children, get_descendants(element)) 69 | 70 | def test_includes_indirect_descendants(self): 71 | grandchild = documents.text("grandchild") 72 | child = documents.run(children=[grandchild]) 73 | element = documents.paragraph(children=[child]) 74 | assert_equal([grandchild, child], get_descendants(element)) 75 | 76 | 77 | class GetDescendantsOfTypeTests(object): 78 | def test_filters_descendants_to_type(self): 79 | tab = documents.tab() 80 | run = documents.run(children=[]) 81 | element = documents.paragraph(children=[tab, run]) 82 | assert_equal([run], get_descendants_of_type(element, documents.Run)) 83 | -------------------------------------------------------------------------------- /tests/styles/parser/tokeniser_tests.py: -------------------------------------------------------------------------------- 1 | from precisely import assert_that, has_attrs, is_sequence 2 | 3 | from mammoth.styles.parser.tokeniser import tokenise 4 | 5 | 6 | def test_unknown_tokens_are_tokenised(): 7 | assert_tokens("~", is_token("unknown", "~")) 8 | 9 | 10 | def test_empty_string_is_tokenised_to_end_of_file_token(): 11 | assert_tokens("") 12 | 13 | 14 | def test_whitespace_is_tokenised(): 15 | assert_tokens(" \t\t ", is_token("whitespace", " \t\t ")) 16 | 17 | 18 | def test_identifiers_are_tokenised(): 19 | assert_tokens("Overture", is_token("identifier", "Overture")) 20 | 21 | 22 | def test_escape_sequences_in_identifiers_are_tokenised(): 23 | assert_tokens(r"\:", is_token("identifier", r"\:")) 24 | 25 | 26 | def test_integers_are_tokenised(): 27 | assert_tokens("123", is_token("integer", "123")) 28 | 29 | 30 | def test_strings_are_tokenised(): 31 | assert_tokens("'Tristan'", is_token("string", "'Tristan'")) 32 | 33 | 34 | def test_escape_sequences_in_strings_are_tokenised(): 35 | assert_tokens(r"'Tristan\''", is_token("string", r"'Tristan\''")) 36 | 37 | 38 | def test_unterminated_strings_are_tokenised(): 39 | assert_tokens("'Tristan", is_token("unterminated string", "'Tristan")) 40 | 41 | 42 | def test_arrows_are_tokenised(): 43 | assert_tokens("=>=>", is_token("symbol", "=>"), is_token("symbol", "=>")) 44 | 45 | 46 | def test_dots_are_tokenised(): 47 | assert_tokens(".", is_token("symbol", ".")) 48 | 49 | 50 | def test_colons_are_tokenised(): 51 | assert_tokens("::", is_token("symbol", ":"), is_token("symbol", ":")) 52 | 53 | 54 | def test_greater_thans_are_tokenised(): 55 | assert_tokens(">>", is_token("symbol", ">"), is_token("symbol", ">")) 56 | 57 | 58 | def test_equals_are_tokenised(): 59 | assert_tokens("==", is_token("symbol", "="), is_token("symbol", "=")) 60 | 61 | 62 | def test_open_parens_are_tokenised(): 63 | assert_tokens("((", is_token("symbol", "("), is_token("symbol", "(")) 64 | 65 | 66 | def test_close_parens_are_tokenised(): 67 | assert_tokens("))", is_token("symbol", ")"), is_token("symbol", ")")) 68 | 69 | 70 | def test_open_square_brackets_are_tokenised(): 71 | assert_tokens("[[", is_token("symbol", "["), is_token("symbol", "[")) 72 | 73 | 74 | def test_close_square_brackets_are_tokenised(): 75 | assert_tokens("]]", is_token("symbol", "]"), is_token("symbol", "]")) 76 | 77 | 78 | def test_choices_are_tokenised(): 79 | assert_tokens("||", is_token("symbol", "|"), is_token("symbol", "|")) 80 | 81 | 82 | def test_bangs_are_tokenised(): 83 | assert_tokens("!!", is_token("symbol", "!"), is_token("symbol", "!")) 84 | 85 | 86 | def test_can_tokenise_multiple_tokens(): 87 | assert_tokens("The Magic Position", 88 | is_token("identifier", "The"), 89 | is_token("whitespace", " "), 90 | is_token("identifier", "Magic"), 91 | is_token("whitespace", " "), 92 | is_token("identifier", "Position"), 93 | ) 94 | 95 | 96 | def assert_tokens(string, *expected): 97 | expected = list(expected) 98 | expected.append(is_token("end", "")) 99 | assert_that( 100 | tokenise(string), 101 | is_sequence(*expected), 102 | ) 103 | 104 | 105 | def is_token(token_type, value): 106 | return has_attrs( 107 | type=token_type, 108 | value=value, 109 | ) 110 | -------------------------------------------------------------------------------- /mammoth/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import io 3 | import os 4 | import shutil 5 | import sys 6 | 7 | import mammoth 8 | from . import writers 9 | 10 | 11 | def main(): 12 | args = _parse_args() 13 | 14 | if args.style_map is None: 15 | style_map = None 16 | else: 17 | with open(args.style_map) as style_map_fileobj: 18 | style_map = style_map_fileobj.read() 19 | 20 | with open(args.path, "rb") as docx_fileobj: 21 | if args.output_dir is None: 22 | convert_image = None 23 | output_path = args.output 24 | else: 25 | convert_image = mammoth.images.img_element(ImageWriter(args.output_dir)) 26 | output_filename = "{0}.html".format(os.path.basename(args.path).rpartition(".")[0]) 27 | output_path = os.path.join(args.output_dir, output_filename) 28 | 29 | result = mammoth.convert( 30 | docx_fileobj, 31 | style_map=style_map, 32 | convert_image=convert_image, 33 | output_format=args.output_format, 34 | ) 35 | for message in result.messages: 36 | sys.stderr.write(message.message) 37 | sys.stderr.write("\n") 38 | 39 | _write_output(output_path, result.value) 40 | 41 | 42 | class ImageWriter(object): 43 | def __init__(self, output_dir): 44 | self._output_dir = output_dir 45 | self._image_number = 1 46 | 47 | def __call__(self, element): 48 | extension = element.content_type.partition("/")[2] 49 | image_filename = "{0}.{1}".format(self._image_number, extension) 50 | with open(os.path.join(self._output_dir, image_filename), "wb") as image_dest: 51 | with element.open() as image_source: 52 | shutil.copyfileobj(image_source, image_dest) 53 | 54 | self._image_number += 1 55 | 56 | return {"src": image_filename} 57 | 58 | 59 | def _write_output(path, contents): 60 | if path is None: 61 | if sys.version_info[0] <= 2: 62 | stdout = sys.stdout 63 | else: 64 | stdout = sys.stdout.buffer 65 | 66 | stdout.write(contents.encode("utf-8")) 67 | stdout.flush() 68 | else: 69 | with io.open(path, "w", encoding="utf-8") as fileobj: 70 | fileobj.write(contents) 71 | 72 | 73 | def _parse_args(): 74 | parser = argparse.ArgumentParser() 75 | parser.add_argument( 76 | "path", 77 | metavar="docx-path", 78 | help="Path to the .docx file to convert.") 79 | 80 | output_group = parser.add_mutually_exclusive_group() 81 | output_group.add_argument( 82 | "output", 83 | nargs="?", 84 | metavar="output-path", 85 | help="Output path for the generated document. Images will be stored inline in the output document. Output is written to stdout if not set.") 86 | output_group.add_argument( 87 | "--output-dir", 88 | help="Output directory for generated HTML and images. Images will be stored in separate files. Mutually exclusive with output-path.") 89 | 90 | parser.add_argument( 91 | "--output-format", 92 | required=False, 93 | choices=writers.formats(), 94 | help="Output format.") 95 | parser.add_argument( 96 | "--style-map", 97 | required=False, 98 | help="File containg a style map.") 99 | return parser.parse_args() 100 | 101 | 102 | if __name__ == "__main__": 103 | main() 104 | 105 | -------------------------------------------------------------------------------- /mammoth/options.py: -------------------------------------------------------------------------------- 1 | from .styles.parser import read_style_mapping 2 | from . import lists, results 3 | 4 | 5 | def read_options(options): 6 | custom_style_map_text = options.pop("style_map", "") or "" 7 | embedded_style_map_text = options.pop("embedded_style_map", "") or "" 8 | include_default_style_map = options.pop("include_default_style_map", True) 9 | 10 | read_style_map_result = results.combine([ 11 | _read_style_map(custom_style_map_text), 12 | _read_style_map(embedded_style_map_text), 13 | ]) 14 | 15 | custom_style_map, embedded_style_map = read_style_map_result.value 16 | style_map = custom_style_map + embedded_style_map 17 | 18 | if include_default_style_map: 19 | style_map += _default_style_map 20 | 21 | options["ignore_empty_paragraphs"] = options.get("ignore_empty_paragraphs", True) 22 | options["style_map"] = style_map 23 | return read_style_map_result.map(lambda _: options) 24 | 25 | 26 | def _read_style_map(style_text): 27 | lines = filter(None, map(_get_line, style_text.split("\n"))) 28 | return results.combine(lists.map(read_style_mapping, lines)) \ 29 | .map(lambda style_mappings: lists.filter(None, style_mappings)) 30 | 31 | 32 | def _get_line(line): 33 | line = line.strip() 34 | if line.startswith("#"): 35 | return None 36 | else: 37 | return line 38 | 39 | 40 | _default_style_map_result = _read_style_map(""" 41 | p.Heading1 => h1:fresh 42 | p.Heading2 => h2:fresh 43 | p.Heading3 => h3:fresh 44 | p.Heading4 => h4:fresh 45 | p.Heading5 => h5:fresh 46 | p.Heading6 => h6:fresh 47 | p[style-name='Heading 1'] => h1:fresh 48 | p[style-name='Heading 2'] => h2:fresh 49 | p[style-name='Heading 3'] => h3:fresh 50 | p[style-name='Heading 4'] => h4:fresh 51 | p[style-name='Heading 5'] => h5:fresh 52 | p[style-name='Heading 6'] => h6:fresh 53 | p[style-name='heading 1'] => h1:fresh 54 | p[style-name='heading 2'] => h2:fresh 55 | p[style-name='heading 3'] => h3:fresh 56 | p[style-name='heading 4'] => h4:fresh 57 | p[style-name='heading 5'] => h5:fresh 58 | p[style-name='heading 6'] => h6:fresh 59 | 60 | # Apple Pages 61 | p.Heading => h1:fresh 62 | p[style-name='Heading'] => h1:fresh 63 | 64 | r[style-name='Strong'] => strong 65 | 66 | p[style-name='footnote text'] => p:fresh 67 | r[style-name='footnote reference'] => 68 | p[style-name='endnote text'] => p:fresh 69 | r[style-name='endnote reference'] => 70 | p[style-name='annotation text'] => p:fresh 71 | r[style-name='annotation reference'] => 72 | 73 | # LibreOffice 74 | p[style-name='Footnote'] => p:fresh 75 | r[style-name='Footnote anchor'] => 76 | p[style-name='Endnote'] => p:fresh 77 | r[style-name='Endnote anchor'] => 78 | 79 | p:unordered-list(1) => ul > li:fresh 80 | p:unordered-list(2) => ul|ol > li > ul > li:fresh 81 | p:unordered-list(3) => ul|ol > li > ul|ol > li > ul > li:fresh 82 | p:unordered-list(4) => ul|ol > li > ul|ol > li > ul|ol > li > ul > li:fresh 83 | p:unordered-list(5) => ul|ol > li > ul|ol > li > ul|ol > li > ul|ol > li > ul > li:fresh 84 | p:ordered-list(1) => ol > li:fresh 85 | p:ordered-list(2) => ul|ol > li > ol > li:fresh 86 | p:ordered-list(3) => ul|ol > li > ul|ol > li > ol > li:fresh 87 | p:ordered-list(4) => ul|ol > li > ul|ol > li > ul|ol > li > ol > li:fresh 88 | p:ordered-list(5) => ul|ol > li > ul|ol > li > ul|ol > li > ul|ol > li > ol > li:fresh 89 | 90 | r[style-name='Hyperlink'] => 91 | 92 | p[style-name='Normal'] => p:fresh 93 | 94 | # Apple Pages 95 | p.Body => p:fresh 96 | p[style-name='Body'] => p:fresh 97 | """) 98 | 99 | 100 | assert not _default_style_map_result.messages 101 | _default_style_map = _default_style_map_result.value 102 | -------------------------------------------------------------------------------- /mammoth/docx/xmlparser.py: -------------------------------------------------------------------------------- 1 | import xml.dom.minidom 2 | 3 | import cobble 4 | 5 | 6 | @cobble.data 7 | class XmlElement(object): 8 | name = cobble.field() 9 | attributes = cobble.field() 10 | children = cobble.field() 11 | 12 | def find_child_or_null(self, name): 13 | return self.find_child(name) or null_xml_element 14 | 15 | def find_child(self, name): 16 | for child in self.children: 17 | if isinstance(child, XmlElement) and child.name == name: 18 | return child 19 | 20 | 21 | def find_children(self, name): 22 | return XmlElementList(filter( 23 | lambda child: child.node_type == node_types.element and child.name == name, 24 | self.children 25 | )) 26 | 27 | 28 | class XmlElementList(object): 29 | def __init__(self, elements): 30 | self._elements = elements 31 | 32 | def __iter__(self): 33 | return iter(self._elements) 34 | 35 | def find_children(self, name): 36 | children = [] 37 | for element in self._elements: 38 | for child in element.find_children(name): 39 | children.append(child) 40 | return XmlElementList(children) 41 | 42 | 43 | class NullXmlElement(object): 44 | attributes = {} 45 | children = [] 46 | 47 | def find_child_or_null(self, name): 48 | return self 49 | 50 | def find_child(self, name): 51 | return None 52 | 53 | 54 | null_xml_element = NullXmlElement() 55 | 56 | 57 | @cobble.data 58 | class XmlText(object): 59 | value = cobble.field() 60 | 61 | 62 | def element(name, attributes=None, children=None): 63 | return XmlElement(name, attributes or {}, children or []) 64 | 65 | text = XmlText 66 | 67 | 68 | class node_types(object): 69 | element = 1 70 | text = 3 71 | 72 | 73 | XmlElement.node_type = node_types.element 74 | XmlText.node_type = node_types.text 75 | 76 | 77 | 78 | def parse_xml(fileobj, namespace_mapping=None): 79 | if namespace_mapping is None: 80 | namespace_prefixes = {} 81 | else: 82 | namespace_prefixes = dict((uri, prefix) for prefix, uri in namespace_mapping) 83 | 84 | document = xml.dom.minidom.parse(fileobj) 85 | 86 | def convert_node(node): 87 | if node.nodeType == xml.dom.Node.ELEMENT_NODE: 88 | return convert_element(node) 89 | elif node.nodeType == xml.dom.Node.TEXT_NODE: 90 | return XmlText(node.nodeValue) 91 | else: 92 | return None 93 | 94 | def convert_element(element): 95 | converted_name = convert_name(element) 96 | 97 | converted_attributes = dict( 98 | (convert_name(attribute), attribute.value) 99 | for attribute in element.attributes.values() 100 | if attribute.namespaceURI != "http://www.w3.org/2000/xmlns/" 101 | ) 102 | 103 | converted_children = [] 104 | for child_node in element.childNodes: 105 | converted_child_node = convert_node(child_node) 106 | if converted_child_node is not None: 107 | converted_children.append(converted_child_node) 108 | 109 | return XmlElement(converted_name, converted_attributes, converted_children) 110 | 111 | def convert_name(node): 112 | if node.namespaceURI is None: 113 | return node.localName 114 | else: 115 | prefix = namespace_prefixes.get(node.namespaceURI) 116 | if prefix is None: 117 | return "{%s}%s" % (node.namespaceURI, node.localName) 118 | else: 119 | return "%s:%s" % (prefix, node.localName) 120 | 121 | return convert_node(document.documentElement) 122 | -------------------------------------------------------------------------------- /mammoth/styles/parser/html_path_parser.py: -------------------------------------------------------------------------------- 1 | import cobble 2 | 3 | from ... import html_paths 4 | from .tokeniser import TokenType 5 | from .token_parser import parse_identifier, parse_string 6 | 7 | 8 | @cobble.data 9 | class _AttributeOrClassName(object): 10 | name = cobble.field() 11 | value = cobble.field() 12 | append = cobble.field() 13 | 14 | 15 | def parse_html_path(tokens): 16 | if tokens.try_skip(TokenType.SYMBOL, "!"): 17 | return html_paths.ignore 18 | else: 19 | return html_paths.path(_parse_html_path_elements(tokens)) 20 | 21 | 22 | def _parse_html_path_elements(tokens): 23 | elements = [] 24 | 25 | if tokens.peek_token_type() == TokenType.IDENTIFIER: 26 | elements.append(_parse_element(tokens)) 27 | 28 | while tokens.try_skip_many(((TokenType.WHITESPACE, None), (TokenType.SYMBOL, ">"))): 29 | tokens.skip(TokenType.WHITESPACE) 30 | elements.append(_parse_element(tokens)) 31 | 32 | return elements 33 | 34 | 35 | def _parse_element(tokens): 36 | tag_names = _parse_tag_names(tokens) 37 | attributes_list = _parse_attribute_or_class_names(tokens) 38 | is_fresh = _parse_is_fresh(tokens) 39 | separator = _parse_separator(tokens) 40 | 41 | attributes = {} 42 | for attribute in attributes_list: 43 | if attribute.append and attributes.get(attribute.name): 44 | attributes[attribute.name] += " " + attribute.value 45 | else: 46 | attributes[attribute.name] = attribute.value 47 | 48 | return html_paths.element( 49 | tag_names, 50 | attributes=attributes, 51 | fresh=is_fresh, 52 | separator=separator, 53 | ) 54 | 55 | 56 | def _parse_tag_names(tokens): 57 | tag_names = [parse_identifier(tokens)] 58 | 59 | while tokens.try_skip(TokenType.SYMBOL, "|"): 60 | tag_names.append(parse_identifier(tokens)) 61 | 62 | return tag_names 63 | 64 | 65 | def _parse_attribute_or_class_names(tokens): 66 | attribute_or_class_names = [] 67 | 68 | while True: 69 | attribute_or_class_name = _try_parse_attribute_or_class_name(tokens) 70 | if attribute_or_class_name is None: 71 | break 72 | else: 73 | attribute_or_class_names.append(attribute_or_class_name) 74 | 75 | return attribute_or_class_names 76 | 77 | 78 | def _try_parse_attribute_or_class_name(tokens): 79 | if tokens.is_next(TokenType.SYMBOL, "["): 80 | return _parse_attribute(tokens) 81 | if tokens.is_next(TokenType.SYMBOL, "."): 82 | return _parse_class_name(tokens) 83 | else: 84 | return None 85 | 86 | 87 | def _parse_attribute(tokens): 88 | tokens.skip(TokenType.SYMBOL, "[") 89 | name = parse_identifier(tokens) 90 | tokens.skip(TokenType.SYMBOL, "=") 91 | value = parse_string(tokens) 92 | tokens.skip(TokenType.SYMBOL, "]") 93 | return _AttributeOrClassName(name=name, value=value, append=False) 94 | 95 | 96 | def _parse_class_name(tokens): 97 | tokens.skip(TokenType.SYMBOL, ".") 98 | class_name = parse_identifier(tokens) 99 | return _AttributeOrClassName(name="class", value=class_name, append=True) 100 | 101 | 102 | def _parse_is_fresh(tokens): 103 | return tokens.try_skip_many(( 104 | (TokenType.SYMBOL, ":"), 105 | (TokenType.IDENTIFIER, "fresh"), 106 | )) 107 | 108 | 109 | def _parse_separator(tokens): 110 | is_separator = tokens.try_skip_many(( 111 | (TokenType.SYMBOL, ":"), 112 | (TokenType.IDENTIFIER, "separator"), 113 | )) 114 | if is_separator: 115 | tokens.skip(TokenType.SYMBOL, "(") 116 | value = parse_string(tokens) 117 | tokens.skip(TokenType.SYMBOL, ")") 118 | return value 119 | else: 120 | return None 121 | -------------------------------------------------------------------------------- /mammoth/html/__init__.py: -------------------------------------------------------------------------------- 1 | from ..lists import flat_map 2 | from .nodes import TextNode, Tag, Element, ForceWrite, NodeVisitor 3 | 4 | 5 | def text(value): 6 | return TextNode(value) 7 | 8 | 9 | def tag(tag_names, attributes=None, collapsible=None, separator=None): 10 | if not isinstance(tag_names, list): 11 | tag_names = [tag_names] 12 | if attributes is None: 13 | attributes = {} 14 | return Tag(tag_names=tag_names, attributes=attributes, collapsible=bool(collapsible), separator=separator) 15 | 16 | 17 | def element(tag_names, attributes=None, children=None, collapsible=None, separator=None): 18 | if children is None: 19 | children = [] 20 | 21 | element_tag = tag(tag_names=tag_names, attributes=attributes, collapsible=collapsible, separator=separator) 22 | return Element(element_tag, children) 23 | 24 | 25 | def collapsible_element(tag_names, attributes=None, children=None): 26 | return element(tag_names, attributes, children, collapsible=True) 27 | 28 | 29 | force_write = ForceWrite() 30 | 31 | 32 | def strip_empty(nodes): 33 | return flat_map(_strip_empty_node, nodes) 34 | 35 | 36 | def _strip_empty_node(node): 37 | return StripEmpty().visit(node) 38 | 39 | 40 | class StripEmpty(NodeVisitor): 41 | def visit_text_node(self, node): 42 | if node.value: 43 | return [node] 44 | else: 45 | return [] 46 | 47 | def visit_element(self, element): 48 | children = strip_empty(element.children) 49 | if len(children) == 0 and not element.is_void(): 50 | return [] 51 | else: 52 | return [Element(element.tag, children)] 53 | 54 | def visit_force_write(self, node): 55 | return [node] 56 | 57 | 58 | def collapse(nodes): 59 | collapsed = [] 60 | 61 | for node in nodes: 62 | _collapsing_add(collapsed, node) 63 | 64 | return collapsed 65 | 66 | class _CollapseNode(NodeVisitor): 67 | def visit_text_node(self, node): 68 | return node 69 | 70 | def visit_element(self, element): 71 | return Element(element.tag, collapse(element.children)) 72 | 73 | def visit_force_write(self, node): 74 | return node 75 | 76 | _collapse_node = _CollapseNode().visit 77 | 78 | 79 | def _collapsing_add(collapsed, node): 80 | collapsed_node = _collapse_node(node) 81 | if not _try_collapse(collapsed, collapsed_node): 82 | collapsed.append(collapsed_node) 83 | 84 | def _try_collapse(collapsed, node): 85 | if not collapsed: 86 | return False 87 | 88 | last = collapsed[-1] 89 | if not isinstance(last, Element) or not isinstance(node, Element): 90 | return False 91 | 92 | if not node.collapsible: 93 | return False 94 | 95 | if not _is_match(last, node): 96 | return False 97 | 98 | if node.separator: 99 | last.children.append(text(node.separator)) 100 | 101 | for child in node.children: 102 | _collapsing_add(last.children, child) 103 | 104 | return True 105 | 106 | def _is_match(first, second): 107 | return first.tag_name in second.tag_names and first.attributes == second.attributes 108 | 109 | 110 | def write(writer, nodes): 111 | visitor = _NodeWriter(writer) 112 | visitor.visit_all(nodes) 113 | 114 | 115 | class _NodeWriter(NodeVisitor): 116 | def __init__(self, writer): 117 | self._writer = writer 118 | 119 | def visit_text_node(self, node): 120 | self._writer.text(node.value) 121 | 122 | def visit_element(self, element): 123 | if element.is_void(): 124 | self._writer.self_closing(element.tag_name, element.attributes) 125 | else: 126 | self._writer.start(element.tag_name, element.attributes) 127 | self.visit_all(element.children) 128 | self._writer.end(element.tag_name) 129 | 130 | def visit_force_write(self, element): 131 | pass 132 | 133 | def visit_all(self, nodes): 134 | for node in nodes: 135 | self.visit(node) 136 | -------------------------------------------------------------------------------- /mammoth/docx/styles_xml.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | 4 | class Styles(object): 5 | @staticmethod 6 | def create(paragraph_styles=None, character_styles=None, table_styles=None, numbering_styles=None): 7 | if paragraph_styles is None: 8 | paragraph_styles = {} 9 | if character_styles is None: 10 | character_styles = {} 11 | if table_styles is None: 12 | table_styles = {} 13 | if numbering_styles is None: 14 | numbering_styles = {} 15 | 16 | return Styles( 17 | paragraph_styles=paragraph_styles, 18 | character_styles=character_styles, 19 | table_styles=table_styles, 20 | numbering_styles=numbering_styles, 21 | ) 22 | 23 | def __init__(self, paragraph_styles, character_styles, table_styles, numbering_styles): 24 | self._paragraph_styles = paragraph_styles 25 | self._character_styles = character_styles 26 | self._table_styles = table_styles 27 | self._numbering_styles = numbering_styles 28 | 29 | def find_paragraph_style_by_id(self, style_id): 30 | return self._paragraph_styles.get(style_id) 31 | 32 | def find_character_style_by_id(self, style_id): 33 | return self._character_styles.get(style_id) 34 | 35 | def find_table_style_by_id(self, style_id): 36 | return self._table_styles.get(style_id) 37 | 38 | def find_numbering_style_by_id(self, style_id): 39 | return self._numbering_styles.get(style_id) 40 | 41 | 42 | Styles.EMPTY = Styles( 43 | paragraph_styles={}, 44 | character_styles={}, 45 | table_styles={}, 46 | numbering_styles={}, 47 | ) 48 | 49 | 50 | def read_styles_xml_element(element): 51 | paragraph_styles = {} 52 | character_styles = {} 53 | table_styles = {} 54 | numbering_styles = {} 55 | styles = { 56 | "paragraph": paragraph_styles, 57 | "character": character_styles, 58 | "table": table_styles, 59 | "numbering": numbering_styles, 60 | } 61 | 62 | for style_element in element.find_children("w:style"): 63 | element_type = style_element.attributes["w:type"] 64 | if element_type == "numbering": 65 | style = _read_numbering_style_element(style_element) 66 | else: 67 | style = _read_style_element(style_element) 68 | 69 | style_set = styles.get(element_type) 70 | 71 | # Per 17.7.4.17 style (Style Definition) of ECMA-376 4th edition Part 1: 72 | # 73 | # > If multiple style definitions each declare the same value for their 74 | # > styleId, then the first such instance shall keep its current 75 | # > identifier with all other instances being reassigned in any manner 76 | # > desired. 77 | # 78 | # For the purpose of conversion, there's no point holding onto styles 79 | # with reassigned style IDs, so we ignore such style definitions. 80 | 81 | if style_set is not None and style.style_id not in style_set: 82 | style_set[style.style_id] = style 83 | 84 | return Styles( 85 | paragraph_styles=paragraph_styles, 86 | character_styles=character_styles, 87 | table_styles=table_styles, 88 | numbering_styles=numbering_styles, 89 | ) 90 | 91 | 92 | Style = collections.namedtuple("Style", ["style_id", "name"]) 93 | 94 | 95 | def _read_style_element(element): 96 | style_id = _read_style_id(element) 97 | name = element.find_child_or_null("w:name").attributes.get("w:val") 98 | return Style(style_id=style_id, name=name) 99 | 100 | 101 | NumberingStyle = collections.namedtuple("NumberingStyle", ["style_id", "num_id"]) 102 | 103 | 104 | def _read_numbering_style_element(element): 105 | style_id = _read_style_id(element) 106 | 107 | num_id = element \ 108 | .find_child_or_null("w:pPr") \ 109 | .find_child_or_null("w:numPr") \ 110 | .find_child_or_null("w:numId") \ 111 | .attributes.get("w:val") 112 | 113 | return NumberingStyle(style_id=style_id, num_id=num_id) 114 | 115 | 116 | def _read_style_id(element): 117 | return element.attributes["w:styleId"] 118 | -------------------------------------------------------------------------------- /tests/html/collapse_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth import html 2 | from ..testing import assert_equal 3 | 4 | 5 | def test_collapsing_does_nothing_to_single_text_node(): 6 | assert_equal( 7 | html.collapse([html.text("Bluebells")]), 8 | [html.text("Bluebells")]) 9 | 10 | 11 | def test_consecutive_fresh_elements_are_not_collapsed(): 12 | assert_equal( 13 | html.collapse([html.element("p"), html.element("p")]), 14 | [html.element("p"), html.element("p")]) 15 | 16 | 17 | def test_consecutive_collapsible_elements_are_collapsed_if_they_have_the_same_tag_and_attributes(): 18 | assert_equal( 19 | [html.collapsible_element("p", {}, [html.text("One"), html.text("Two")])], 20 | html.collapse([ 21 | html.collapsible_element("p", {}, [html.text("One")]), 22 | html.collapsible_element("p", {}, [html.text("Two")]) 23 | ])) 24 | 25 | 26 | def test_elements_with_different_tag_names_are_not_collapsed(): 27 | assert_equal( 28 | [ 29 | html.collapsible_element("p", {}, [html.text("One")]), 30 | html.collapsible_element("div", {}, [html.text("Two")]) 31 | ], 32 | 33 | html.collapse([ 34 | html.collapsible_element("p", {}, [html.text("One")]), 35 | html.collapsible_element("div", {}, [html.text("Two")]) 36 | ])) 37 | 38 | 39 | def test_elements_with_different_attributes_are_not_collapsed(): 40 | assert_equal( 41 | [ 42 | html.collapsible_element("p", {"id": "a"}, [html.text("One")]), 43 | html.collapsible_element("p", {}, [html.text("Two")]) 44 | ], 45 | 46 | html.collapse([ 47 | html.collapsible_element("p", {"id": "a"}, [html.text("One")]), 48 | html.collapsible_element("p", {}, [html.text("Two")]) 49 | ])) 50 | 51 | 52 | def test_children_of_collapsed_element_can_collapse_with_children_of_previous_element(): 53 | assert_equal( 54 | [ 55 | html.collapsible_element("blockquote", {}, [ 56 | html.collapsible_element("p", {}, [ 57 | html.text("One"), 58 | html.text("Two") 59 | ]) 60 | ]), 61 | ], 62 | 63 | html.collapse([ 64 | html.collapsible_element("blockquote", {}, [ 65 | html.collapsible_element("p", {}, [html.text("One")]) 66 | ]), 67 | html.collapsible_element("blockquote", {}, [ 68 | html.collapsible_element("p", {}, [html.text("Two")]) 69 | ]), 70 | ])) 71 | 72 | 73 | def test_collapsible_element_can_collapse_into_previous_fresh_element(): 74 | assert_equal( 75 | [html.element("p", {}, [html.text("One"), html.text("Two")])], 76 | html.collapse([ 77 | html.element("p", {}, [html.text("One")]), 78 | html.collapsible_element("p", {}, [html.text("Two")]) 79 | ])) 80 | 81 | 82 | def test_element_with_choice_of_tag_names_can_collapse_into_previous_element_if_it_has_one_of_those_tag_names_as_its_main_tag_name(): 83 | assert_equal( 84 | [html.collapsible_element(["ol"])], 85 | html.collapse([ 86 | html.collapsible_element("ol"), 87 | html.collapsible_element(["ul", "ol"]) 88 | ])) 89 | 90 | assert_equal( 91 | [ 92 | html.collapsible_element(["ul", "ol"]), 93 | html.collapsible_element("ol") 94 | ], 95 | html.collapse([ 96 | html.collapsible_element(["ul", "ol"]), 97 | html.collapsible_element("ol") 98 | ])) 99 | 100 | 101 | def test_when_separator_is_present_then_separator_is_prepended_to_collapsed_element(): 102 | assert_equal( 103 | [ 104 | html.element("pre", collapsible=False, children=[ 105 | html.text("Hello"), 106 | html.text("\n"), 107 | html.text(" the"), 108 | html.text("re") 109 | ]) 110 | ], 111 | html.collapse([ 112 | html.element("pre", collapsible=False, children=[html.text("Hello")]), 113 | html.element("pre", collapsible=True, separator="\n", children=[html.text(" the"), html.text("re")]), 114 | ]), 115 | ) 116 | -------------------------------------------------------------------------------- /mammoth/docx/numbering_xml.py: -------------------------------------------------------------------------------- 1 | import cobble 2 | 3 | from ..documents import numbering_level 4 | from .styles_xml import Styles 5 | 6 | 7 | def read_numbering_xml_element(element, styles): 8 | abstract_nums = _read_abstract_nums(element) 9 | nums = _read_nums(element) 10 | return Numbering(abstract_nums=abstract_nums, nums=nums, styles=styles) 11 | 12 | 13 | def _read_abstract_nums(element): 14 | abstract_num_elements = element.find_children("w:abstractNum") 15 | return dict(map(_read_abstract_num, abstract_num_elements)) 16 | 17 | 18 | def _read_abstract_num(element): 19 | abstract_num_id = element.attributes.get("w:abstractNumId") 20 | levels = _read_abstract_num_levels(element) 21 | num_style_link = element.find_child_or_null("w:numStyleLink").attributes.get("w:val") 22 | return abstract_num_id, _AbstractNum(levels=levels, num_style_link=num_style_link) 23 | 24 | 25 | @cobble.data 26 | class _AbstractNum(object): 27 | levels = cobble.field() 28 | num_style_link = cobble.field() 29 | 30 | 31 | @cobble.data 32 | class _AbstractNumLevel(object): 33 | level_index = cobble.field() 34 | is_ordered = cobble.field() 35 | paragraph_style_id = cobble.field() 36 | 37 | 38 | def _read_abstract_num_levels(element): 39 | levels = {} 40 | 41 | # Some malformed documents define numbering levels without an index, and 42 | # reference the numbering using a w:numPr element without a w:ilvl child. 43 | # To handle such cases, we assume a level of 0 as a fallback. 44 | level_without_index = None 45 | 46 | for level_element in element.find_children("w:lvl"): 47 | level = _read_abstract_num_level(level_element) 48 | if level.level_index is None: 49 | level.level_index = "0" 50 | level_without_index = level 51 | else: 52 | levels[level.level_index] = level 53 | 54 | if level_without_index is not None and level_without_index.level_index not in levels: 55 | levels[level_without_index.level_index] = level_without_index 56 | 57 | return levels 58 | 59 | 60 | def _read_abstract_num_level(element): 61 | level_index = element.attributes.get("w:ilvl") 62 | num_fmt = element.find_child_or_null("w:numFmt").attributes.get("w:val") 63 | is_ordered = num_fmt != "bullet" 64 | paragraph_style_id = element.find_child_or_null("w:pStyle").attributes.get("w:val") 65 | return _AbstractNumLevel( 66 | level_index=level_index, 67 | is_ordered=is_ordered, 68 | paragraph_style_id=paragraph_style_id, 69 | ) 70 | 71 | 72 | def _read_nums(element): 73 | num_elements = element.find_children("w:num") 74 | return dict( 75 | _read_num(num_element) 76 | for num_element in num_elements 77 | ) 78 | 79 | 80 | def _read_num(element): 81 | num_id = element.attributes.get("w:numId") 82 | abstract_num_id = element.find_child_or_null("w:abstractNumId").attributes["w:val"] 83 | return num_id, _Num(abstract_num_id=abstract_num_id) 84 | 85 | 86 | @cobble.data 87 | class _Num(object): 88 | abstract_num_id = cobble.field() 89 | 90 | 91 | class Numbering(object): 92 | def __init__(self, abstract_nums, nums, styles): 93 | self._abstract_nums = abstract_nums 94 | self._levels_by_paragraph_style_id = dict( 95 | (level.paragraph_style_id, self._to_numbering_level(level)) 96 | for abstract_num in abstract_nums.values() 97 | for level in abstract_num.levels.values() 98 | if level.paragraph_style_id is not None 99 | ) 100 | self._nums = nums 101 | self._styles = styles 102 | 103 | def find_level(self, num_id, level): 104 | num = self._nums.get(num_id) 105 | if num is None: 106 | return None 107 | else: 108 | abstract_num = self._abstract_nums.get(num.abstract_num_id) 109 | if abstract_num is None: 110 | return None 111 | elif abstract_num.num_style_link is None: 112 | return self._to_numbering_level(abstract_num.levels.get(level)) 113 | else: 114 | style = self._styles.find_numbering_style_by_id(abstract_num.num_style_link) 115 | return self.find_level(style.num_id, level) 116 | 117 | def find_level_by_paragraph_style_id(self, style_id): 118 | return self._levels_by_paragraph_style_id.get(style_id) 119 | 120 | def _to_numbering_level(self, abstract_num_level): 121 | if abstract_num_level is None: 122 | return None 123 | else: 124 | return numbering_level( 125 | level_index=abstract_num_level.level_index, 126 | is_ordered=abstract_num_level.is_ordered, 127 | ) 128 | 129 | 130 | Numbering.EMPTY = Numbering(abstract_nums={}, nums={}, styles=Styles.EMPTY) 131 | -------------------------------------------------------------------------------- /mammoth/styles/parser/document_matcher_parser.py: -------------------------------------------------------------------------------- 1 | from ... import documents, document_matchers 2 | from .errors import LineParseError 3 | from .tokeniser import TokenType 4 | from .token_parser import try_parse_class_name, parse_string 5 | 6 | 7 | def parse_document_matcher(tokens): 8 | if tokens.try_skip(TokenType.IDENTIFIER, "p"): 9 | style_id = try_parse_class_name(tokens) 10 | style_name = _parse_style_name(tokens) 11 | numbering = _parse_numbering(tokens) 12 | 13 | return document_matchers.paragraph( 14 | style_id=style_id, 15 | style_name=style_name, 16 | numbering=numbering, 17 | ) 18 | 19 | elif tokens.try_skip(TokenType.IDENTIFIER, "r"): 20 | style_id = try_parse_class_name(tokens) 21 | style_name = _parse_style_name(tokens) 22 | 23 | return document_matchers.run( 24 | style_id=style_id, 25 | style_name=style_name, 26 | ) 27 | 28 | elif tokens.try_skip(TokenType.IDENTIFIER, "table"): 29 | style_id = try_parse_class_name(tokens) 30 | style_name = _parse_style_name(tokens) 31 | 32 | return document_matchers.table( 33 | style_id=style_id, 34 | style_name=style_name, 35 | ) 36 | 37 | elif tokens.try_skip(TokenType.IDENTIFIER, "b"): 38 | return document_matchers.bold 39 | 40 | elif tokens.try_skip(TokenType.IDENTIFIER, "i"): 41 | return document_matchers.italic 42 | 43 | elif tokens.try_skip(TokenType.IDENTIFIER, "u"): 44 | return document_matchers.underline 45 | 46 | elif tokens.try_skip(TokenType.IDENTIFIER, "strike"): 47 | return document_matchers.strikethrough 48 | 49 | elif tokens.try_skip(TokenType.IDENTIFIER, "all-caps"): 50 | return document_matchers.all_caps 51 | 52 | elif tokens.try_skip(TokenType.IDENTIFIER, "small-caps"): 53 | return document_matchers.small_caps 54 | 55 | elif tokens.try_skip(TokenType.IDENTIFIER, "highlight"): 56 | return _parse_highlight(tokens) 57 | 58 | elif tokens.try_skip(TokenType.IDENTIFIER, "comment-reference"): 59 | return document_matchers.comment_reference 60 | 61 | elif tokens.try_skip(TokenType.IDENTIFIER, "br"): 62 | return _parse_break(tokens) 63 | 64 | else: 65 | raise LineParseError("Unrecognised document element: {0}".format(tokens.next_value(TokenType.IDENTIFIER))) 66 | 67 | def _parse_style_name(tokens): 68 | if tokens.try_skip(TokenType.SYMBOL, "["): 69 | tokens.skip(TokenType.IDENTIFIER, "style-name") 70 | string_matcher = _parse_string_matcher(tokens) 71 | tokens.skip(TokenType.SYMBOL, "]") 72 | return string_matcher 73 | else: 74 | return None 75 | 76 | 77 | def _parse_string_matcher(tokens): 78 | if tokens.try_skip(TokenType.SYMBOL, "="): 79 | return document_matchers.equal_to(parse_string(tokens)) 80 | elif tokens.try_skip(TokenType.SYMBOL, "^="): 81 | return document_matchers.starts_with(parse_string(tokens)) 82 | else: 83 | raise LineParseError("Unrecognised string matcher: {0}".format(tokens.next_value())) 84 | 85 | def _parse_numbering(tokens): 86 | if tokens.try_skip(TokenType.SYMBOL, ":"): 87 | is_ordered = _parse_list_type(tokens) 88 | tokens.skip(TokenType.SYMBOL, "(") 89 | level = int(tokens.next_value(TokenType.INTEGER)) - 1 90 | tokens.skip(TokenType.SYMBOL, ")") 91 | return documents.numbering_level(level, is_ordered=is_ordered) 92 | 93 | 94 | def _parse_list_type(tokens): 95 | list_type = tokens.next_value(TokenType.IDENTIFIER) 96 | if list_type == "ordered-list": 97 | return True 98 | elif list_type == "unordered-list": 99 | return False 100 | else: 101 | raise LineParseError("Unrecognised list type: {0}".format(list_type)) 102 | 103 | 104 | def _parse_highlight(tokens): 105 | if tokens.try_skip(TokenType.SYMBOL, "["): 106 | tokens.skip(TokenType.IDENTIFIER, "color") 107 | tokens.skip(TokenType.SYMBOL, "=") 108 | color = parse_string(tokens) 109 | tokens.skip(TokenType.SYMBOL, "]"); 110 | else: 111 | color = None 112 | 113 | return document_matchers.highlight(color=color) 114 | 115 | 116 | def _parse_break(tokens): 117 | tokens.skip(TokenType.SYMBOL, "[") 118 | tokens.skip(TokenType.IDENTIFIER, "type") 119 | tokens.skip(TokenType.SYMBOL, "=") 120 | type_name = parse_string(tokens) 121 | tokens.skip(TokenType.SYMBOL, "]"); 122 | 123 | if type_name == "line": 124 | return document_matchers.line_break 125 | elif type_name == "page": 126 | return document_matchers.page_break 127 | elif type_name == "column": 128 | return document_matchers.column_break 129 | else: 130 | raise LineParseError("Unrecognised break type: {0}".format(type_name)) 131 | -------------------------------------------------------------------------------- /tests/docx/style_map_tests.py: -------------------------------------------------------------------------------- 1 | import io 2 | from zipfile import ZipFile 3 | 4 | from mammoth.docx.style_map import write_style_map, read_style_map 5 | from mammoth.zips import open_zip 6 | from mammoth.docx import xmlparser as xml 7 | from ..testing import assert_equal 8 | 9 | 10 | def test_reading_embedded_style_map_on_document_without_embedded_style_map_returns_none(): 11 | fileobj = _normal_docx() 12 | assert_equal(None, read_style_map(fileobj)) 13 | 14 | 15 | def test_writing_style_map_preserves_unrelated_files(): 16 | fileobj = _normal_docx() 17 | write_style_map(fileobj, "p => h1") 18 | with open_zip(fileobj, "r") as zip_file: 19 | assert_equal("placeholder", zip_file.read_str("placeholder")) 20 | 21 | def test_embedded_style_map_can_be_read_after_being_written(): 22 | fileobj = _normal_docx() 23 | write_style_map(fileobj, "p => h1") 24 | assert_equal("p => h1", read_style_map(fileobj)) 25 | 26 | 27 | def test_embedded_style_map_is_written_to_separate_file(): 28 | fileobj = _normal_docx() 29 | write_style_map(fileobj, "p => h1") 30 | with open_zip(fileobj, "r") as zip_file: 31 | assert_equal("p => h1", zip_file.read_str("mammoth/style-map")) 32 | 33 | 34 | def test_embedded_style_map_is_referenced_in_relationships(): 35 | fileobj = _normal_docx() 36 | write_style_map(fileobj, "p => h1") 37 | assert_equal(expected_relationships_xml, _read_relationships_xml(fileobj)) 38 | 39 | def test_embedded_style_map_has_override_content_type_in_content_types_xml(): 40 | fileobj = _normal_docx() 41 | write_style_map(fileobj, "p => h1") 42 | assert_equal(expected_content_types_xml, _read_content_types_xml(fileobj)) 43 | 44 | 45 | def test_can_overwrite_existing_style_map(): 46 | fileobj = _normal_docx() 47 | write_style_map(fileobj, "p => h1") 48 | write_style_map(fileobj, "p => h2") 49 | with open_zip(fileobj, "r") as zip_file: 50 | assert_equal("p => h2", read_style_map(fileobj)) 51 | _assert_no_duplicates(zip_file._zip_file.namelist()) 52 | assert_equal(expected_relationships_xml, _read_relationships_xml(fileobj)) 53 | assert_equal(expected_content_types_xml, _read_content_types_xml(fileobj)) 54 | 55 | 56 | def _read_relationships_xml(fileobj): 57 | with open_zip(fileobj, "r") as zip_file: 58 | return xml.parse_xml( 59 | io.StringIO(zip_file.read_str("word/_rels/document.xml.rels")), 60 | [("r", "http://schemas.openxmlformats.org/package/2006/relationships")], 61 | ) 62 | 63 | 64 | def _read_content_types_xml(fileobj): 65 | with open_zip(fileobj, "r") as zip_file: 66 | return xml.parse_xml( 67 | io.StringIO(zip_file.read_str("[Content_Types].xml")), 68 | [("ct", "http://schemas.openxmlformats.org/package/2006/content-types")], 69 | ) 70 | 71 | 72 | original_relationships_xml = ('' + 73 | '' + 74 | '' + 75 | '') 76 | 77 | expected_relationships_xml = xml.element("r:Relationships", {}, [ 78 | xml.element("r:Relationship", {"Id": "rId3", "Type": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "Target": "settings.xml"}), 79 | xml.element("r:Relationship", {"Id": "rMammothStyleMap", "Type": "http://schemas.zwobble.org/mammoth/style-map", "Target": "/mammoth/style-map"}), 80 | ]) 81 | 82 | original_content_types_xml = ('' + 83 | '' + 84 | '' + 85 | '' 86 | ) 87 | 88 | expected_content_types_xml = xml.element("ct:Types", {}, [ 89 | xml.element("ct:Default", {"Extension": "png", "ContentType": "image/png"}), 90 | xml.element("ct:Override", {"PartName": "/mammoth/style-map", "ContentType": "text/prs.mammoth.style-map"}), 91 | ]) 92 | 93 | 94 | def _normal_docx(): 95 | fileobj = io.BytesIO() 96 | zip_file = ZipFile(fileobj, "w") 97 | try: 98 | zip_file.writestr("placeholder", "placeholder") 99 | zip_file.writestr("word/_rels/document.xml.rels", original_relationships_xml) 100 | zip_file.writestr("[Content_Types].xml", original_content_types_xml) 101 | expected_relationships_xml 102 | finally: 103 | zip_file.close() 104 | return fileobj 105 | 106 | 107 | def _assert_no_duplicates(values): 108 | counts = {} 109 | for value in values: 110 | counts[value] = counts.get(value, 0) + 1 111 | for value, count in counts.items(): 112 | if count != 1: 113 | assert False, "{0} has count of {1}".format(value, count) 114 | -------------------------------------------------------------------------------- /tests/docx/styles_xml_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth.docx.xmlparser import element as xml_element 2 | from mammoth.docx.styles_xml import read_styles_xml_element 3 | from ..testing import assert_equal 4 | 5 | 6 | def test_paragraph_style_is_null_if_no_style_with_that_id_exists(): 7 | element = xml_element("w:styles") 8 | styles = read_styles_xml_element(element) 9 | assert_equal(None, styles.find_paragraph_style_by_id("Heading1")) 10 | 11 | 12 | def test_paragraph_style_can_be_found_by_id(): 13 | element = xml_element("w:styles", {}, [ 14 | _paragraph_style_element("Heading1", "Heading 1"), 15 | ]) 16 | styles = read_styles_xml_element(element) 17 | assert_equal( 18 | "Heading1", 19 | styles.find_paragraph_style_by_id("Heading1").style_id 20 | ) 21 | 22 | 23 | def test_character_style_can_be_found_by_id(): 24 | element = xml_element("w:styles", {}, [ 25 | _character_style_element("Heading1Char", "Heading 1 Char"), 26 | ]) 27 | styles = read_styles_xml_element(element) 28 | assert_equal( 29 | "Heading1Char", 30 | styles.find_character_style_by_id("Heading1Char").style_id 31 | ) 32 | 33 | 34 | def test_table_style_can_be_found_by_id(): 35 | element = xml_element("w:styles", {}, [ 36 | _table_style_element("TableNormal", "Normal Table"), 37 | ]) 38 | styles = read_styles_xml_element(element) 39 | assert_equal( 40 | "TableNormal", 41 | styles.find_table_style_by_id("TableNormal").style_id 42 | ) 43 | 44 | 45 | def test_paragraph_and_character_styles_are_distinct(): 46 | element = xml_element("w:styles", {}, [ 47 | _paragraph_style_element("Heading1", "Heading 1"), 48 | _character_style_element("Heading1Char", "Heading 1 Char"), 49 | ]) 50 | styles = read_styles_xml_element(element) 51 | assert_equal(None, styles.find_character_style_by_id("Heading1")) 52 | assert_equal(None, styles.find_paragraph_style_by_id("Heading1Char")) 53 | 54 | 55 | def test_styles_include_names(): 56 | element = xml_element("w:styles", {}, [ 57 | _paragraph_style_element("Heading1", "Heading 1"), 58 | ]) 59 | styles = read_styles_xml_element(element) 60 | assert_equal( 61 | "Heading 1", 62 | styles.find_paragraph_style_by_id("Heading1").name 63 | ) 64 | 65 | 66 | def test_style_name_is_none_if_name_element_does_not_exist(): 67 | element = xml_element("w:styles", {}, [ 68 | _style_without_name_element("paragraph", "Heading1"), 69 | _style_without_name_element("character", "Heading1Char") 70 | ]) 71 | styles = read_styles_xml_element(element) 72 | assert_equal(None, styles.find_paragraph_style_by_id("Heading1").name) 73 | assert_equal(None, styles.find_character_style_by_id("Heading1Char").name) 74 | 75 | 76 | def test_numbering_style_is_none_if_no_style_with_that_id_exists(): 77 | element = xml_element("w:styles", {}, []) 78 | styles = read_styles_xml_element(element) 79 | assert_equal(None, styles.find_numbering_style_by_id("List1")) 80 | 81 | 82 | def test_numbering_style_has_none_num_id_if_style_has_no_paragraph_properties(): 83 | element = xml_element("w:styles", {}, [ 84 | xml_element("w:style", {"w:type": "numbering", "w:styleId": "List1"}), 85 | ]) 86 | styles = read_styles_xml_element(element) 87 | assert_equal(None, styles.find_numbering_style_by_id("List1").num_id) 88 | 89 | 90 | def test_numbering_style_has_num_id_read_from_paragraph_properties(): 91 | element = xml_element("w:styles", {}, [ 92 | xml_element("w:style", {"w:type": "numbering", "w:styleId": "List1"}, [ 93 | xml_element("w:pPr", {}, [ 94 | xml_element("w:numPr", {}, [ 95 | xml_element("w:numId", {"w:val": "42"}) 96 | ]), 97 | ]), 98 | ]), 99 | ]) 100 | styles = read_styles_xml_element(element) 101 | assert_equal("42", styles.find_numbering_style_by_id("List1").num_id) 102 | 103 | 104 | def test_when_multiple_style_elements_have_same_style_id_then_only_first_element_is_used(): 105 | element = xml_element("w:styles", {}, [ 106 | _table_style_element("TableNormal", "Normal Table"), 107 | _table_style_element("TableNormal", "Table Normal"), 108 | ]) 109 | styles = read_styles_xml_element(element) 110 | assert_equal( 111 | "Normal Table", 112 | styles.find_table_style_by_id("TableNormal").name 113 | ) 114 | 115 | 116 | def _paragraph_style_element(style_id, name): 117 | return _style_element("paragraph", style_id, name) 118 | 119 | def _character_style_element(style_id, name): 120 | return _style_element("character", style_id, name) 121 | 122 | def _table_style_element(style_id, name): 123 | return _style_element("table", style_id, name) 124 | 125 | def _style_element(element_type, style_id, name): 126 | children = [xml_element("w:name", {"w:val": name}, [])] 127 | return _style_element_with_children(element_type, style_id, children) 128 | 129 | def _style_without_name_element(element_type, style_id): 130 | return _style_element_with_children(element_type, style_id, []) 131 | 132 | def _style_element_with_children(element_type, style_id, children): 133 | attributes = {"w:type": element_type, "w:styleId": style_id} 134 | return xml_element("w:style", attributes, children) 135 | -------------------------------------------------------------------------------- /tests/styles/parser/document_matcher_parser_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth import documents, document_matchers 2 | from mammoth.styles.parser.document_matcher_parser import parse_document_matcher 3 | from mammoth.styles.parser.errors import LineParseError 4 | from mammoth.styles.parser.tokeniser import tokenise 5 | from mammoth.styles.parser.token_iterator import TokenIterator 6 | from ...testing import assert_equal, assert_raises 7 | 8 | 9 | def test_unrecognised_document_element_raises_error(): 10 | error = assert_raises(LineParseError, lambda: read_document_matcher("x")) 11 | assert_equal("Unrecognised document element: x", str(error)) 12 | 13 | 14 | def test_reads_plain_paragraph(): 15 | assert_equal( 16 | document_matchers.paragraph(), 17 | read_document_matcher("p") 18 | ) 19 | 20 | 21 | def test_reads_paragraph_with_style_id(): 22 | assert_equal( 23 | document_matchers.paragraph(style_id="Heading1"), 24 | read_document_matcher("p.Heading1") 25 | ) 26 | 27 | 28 | def test_reads_paragraph_with_exact_style_name(): 29 | assert_equal( 30 | document_matchers.paragraph(style_name=document_matchers.equal_to("Heading 1")), 31 | read_document_matcher("p[style-name='Heading 1']") 32 | ) 33 | 34 | 35 | def test_reads_paragraph_with_style_name_prefix(): 36 | assert_equal( 37 | document_matchers.paragraph(style_name=document_matchers.starts_with("Heading")), 38 | read_document_matcher("p[style-name^='Heading']") 39 | ) 40 | 41 | 42 | def test_unrecognised_string_matcher_raises_error(): 43 | error = assert_raises(LineParseError, lambda: read_document_matcher("p[style-name*='Heading']")) 44 | assert_equal("Unrecognised string matcher: *", str(error)) 45 | 46 | 47 | def test_reads_paragraph_ordered_list(): 48 | assert_equal( 49 | document_matchers.paragraph(numbering=documents.numbering_level(1, is_ordered=True)), 50 | read_document_matcher("p:ordered-list(2)") 51 | ) 52 | 53 | 54 | def test_reads_paragraph_unordered_list(): 55 | assert_equal( 56 | document_matchers.paragraph(numbering=documents.numbering_level(1, is_ordered=False)), 57 | read_document_matcher("p:unordered-list(2)") 58 | ) 59 | 60 | 61 | def test_unrecognised_list_type_raises_error(): 62 | error = assert_raises(LineParseError, lambda: read_document_matcher("p:blah")) 63 | assert_equal("Unrecognised list type: blah", str(error)) 64 | 65 | 66 | def test_reads_plain_run(): 67 | assert_equal( 68 | document_matchers.run(), 69 | read_document_matcher("r") 70 | ) 71 | 72 | 73 | def test_reads_run_with_style_id(): 74 | assert_equal( 75 | document_matchers.run(style_id="Emphasis"), 76 | read_document_matcher("r.Emphasis") 77 | ) 78 | 79 | 80 | def test_reads_run_with_style_name(): 81 | assert_equal( 82 | document_matchers.run(style_name=document_matchers.equal_to("Emphasis")), 83 | read_document_matcher("r[style-name='Emphasis']") 84 | ) 85 | 86 | 87 | def test_reads_plain_table(): 88 | assert_equal( 89 | document_matchers.table(), 90 | read_document_matcher("table") 91 | ) 92 | 93 | 94 | def test_reads_table_with_style_id(): 95 | assert_equal( 96 | document_matchers.table(style_id="TableNormal"), 97 | read_document_matcher("table.TableNormal") 98 | ) 99 | 100 | 101 | def test_reads_table_with_style_name(): 102 | assert_equal( 103 | document_matchers.table(style_name=document_matchers.equal_to("Normal Table")), 104 | read_document_matcher("table[style-name='Normal Table']") 105 | ) 106 | 107 | 108 | def test_reads_bold(): 109 | assert_equal( 110 | document_matchers.bold, 111 | read_document_matcher("b") 112 | ) 113 | 114 | def test_reads_italic(): 115 | assert_equal( 116 | document_matchers.italic, 117 | read_document_matcher("i") 118 | ) 119 | 120 | def test_reads_underline(): 121 | assert_equal( 122 | document_matchers.underline, 123 | read_document_matcher("u") 124 | ) 125 | 126 | def test_reads_strikethrough(): 127 | assert_equal( 128 | document_matchers.strikethrough, 129 | read_document_matcher("strike") 130 | ) 131 | 132 | def test_reads_all_caps(): 133 | assert_equal( 134 | document_matchers.all_caps, 135 | read_document_matcher("all-caps") 136 | ) 137 | 138 | def test_reads_small_caps(): 139 | assert_equal( 140 | document_matchers.small_caps, 141 | read_document_matcher("small-caps") 142 | ) 143 | 144 | def test_reads_highlight_without_color(): 145 | assert_equal( 146 | document_matchers.highlight(), 147 | read_document_matcher("highlight") 148 | ) 149 | 150 | def test_reads_highlight_with_color(): 151 | assert_equal( 152 | document_matchers.highlight(color="yellow"), 153 | read_document_matcher("highlight[color='yellow']") 154 | ) 155 | 156 | def test_reads_comment_reference(): 157 | assert_equal( 158 | document_matchers.comment_reference, 159 | read_document_matcher("comment-reference") 160 | ) 161 | 162 | def test_reads_line_breaks(): 163 | assert_equal( 164 | document_matchers.line_break, 165 | read_document_matcher("br[type='line']"), 166 | ) 167 | 168 | def test_reads_page_breaks(): 169 | assert_equal( 170 | document_matchers.page_break, 171 | read_document_matcher("br[type='page']"), 172 | ) 173 | 174 | def test_reads_column_breaks(): 175 | assert_equal( 176 | document_matchers.column_break, 177 | read_document_matcher("br[type='column']"), 178 | ) 179 | 180 | 181 | def test_unrecognised_break_type_raises_error(): 182 | error = assert_raises(LineParseError, lambda: read_document_matcher("br[type='unknownBreakType']")) 183 | assert_equal("Unrecognised break type: unknownBreakType", str(error)) 184 | 185 | 186 | def read_document_matcher(string): 187 | return parse_document_matcher(TokenIterator(tokenise(string))) 188 | -------------------------------------------------------------------------------- /tests/writers/markdown_tests.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from mammoth.writers.markdown import MarkdownWriter 4 | from ..testing import assert_equal 5 | 6 | 7 | def test_special_markdown_characters_are_escaped(): 8 | writer = _create_writer() 9 | writer.text(r"\*") 10 | assert_equal(r"\\\*", writer.as_string()) 11 | 12 | 13 | def test_unrecognised_elements_are_treated_as_normal_text(): 14 | writer = _create_writer() 15 | writer.start("blah"); 16 | writer.text("Hello"); 17 | writer.end("blah"); 18 | assert_equal("Hello", writer.as_string()) 19 | 20 | 21 | def test_paragraphs_are_terminated_with_double_new_line(): 22 | writer = _create_writer() 23 | writer.start("p"); 24 | writer.text("Hello"); 25 | writer.end("p"); 26 | assert_equal("Hello\n\n", writer.as_string()) 27 | 28 | 29 | def test_h1_elements_are_converted_to_heading_with_leading_hash(): 30 | writer = _create_writer() 31 | writer.start("h1"); 32 | writer.text("Hello"); 33 | writer.end("h1"); 34 | assert_equal("# Hello\n\n", writer.as_string()) 35 | 36 | 37 | def test_h6_elements_are_converted_to_heading_with_six_leading_hashes(): 38 | writer = _create_writer() 39 | writer.start("h6"); 40 | writer.text("Hello"); 41 | writer.end("h6"); 42 | assert_equal("###### Hello\n\n", writer.as_string()) 43 | 44 | 45 | def test_br_is_written_as_two_spaces_followed_by_newline(): 46 | writer = _create_writer() 47 | writer.text("Hello"); 48 | writer.self_closing("br"); 49 | assert_equal("Hello \n", writer.as_string()) 50 | 51 | 52 | def test_strong_text_is_surrounded_by_two_underscores(): 53 | writer = _create_writer() 54 | writer.text("Hello "); 55 | writer.start("strong"); 56 | writer.text("World") 57 | writer.end("strong") 58 | assert_equal("Hello __World__", writer.as_string()) 59 | 60 | 61 | def test_emphasised_text_is_surrounded_by_one_asterix(): 62 | writer = _create_writer() 63 | writer.text("Hello "); 64 | writer.start("em"); 65 | writer.text("World") 66 | writer.end("em") 67 | assert_equal("Hello *World*", writer.as_string()) 68 | 69 | 70 | def test_anchor_tags_are_written_as_hyperlinks(): 71 | writer = _create_writer() 72 | writer.start("a", {"href": "http://example.com"}); 73 | writer.text("Hello"); 74 | writer.end("a"); 75 | assert_equal("[Hello](http://example.com)", writer.as_string()) 76 | 77 | 78 | def test_anchor_tags_without_href_attribute_are_treated_as_ordinary_text(): 79 | writer = _create_writer() 80 | writer.start("a"); 81 | writer.text("Hello"); 82 | writer.end("a"); 83 | assert_equal("Hello", writer.as_string()) 84 | 85 | 86 | def test_elements_with_ids_have_anchor_tags_with_ids_appended_to_start_of_markdown_element(): 87 | writer = _create_writer() 88 | writer.start("h1", {"id": "start"}) 89 | writer.text("Hello") 90 | writer.end("h1") 91 | assert_equal('#
Hello\n\n', writer.as_string()) 92 | 93 | 94 | def test_links_have_anchors_before_opening_square_bracket(): 95 | writer = _create_writer() 96 | writer.start("a", {"href": "http://example.com", "id": "start"}) 97 | writer.text("Hello") 98 | writer.end("a") 99 | assert_equal('[Hello](http://example.com)', writer.as_string()) 100 | 101 | 102 | def test_image_elements_are_written_as_markdown_images(): 103 | writer = _create_writer() 104 | writer.self_closing("img", {"src": "http://example.com/image.jpg", "alt": "Alt Text"}) 105 | assert_equal("![Alt Text](http://example.com/image.jpg)", writer.as_string()) 106 | 107 | 108 | def test_images_are_written_even_if_they_dont_have_alt_text(): 109 | writer = _create_writer() 110 | writer.self_closing("img", {"src": "http://example.com/image.jpg"}) 111 | assert_equal("![](http://example.com/image.jpg)", writer.as_string()) 112 | 113 | 114 | def test_images_are_written_even_if_they_dont_have_a_src_attribute(): 115 | writer = _create_writer() 116 | writer.self_closing("img", {"alt": "Alt Text"}) 117 | assert_equal("![Alt Text]()", writer.as_string()) 118 | 119 | 120 | def test_image_elements_are_ignored_if_they_have_no_src_and_no_alt_text(): 121 | writer = _create_writer() 122 | writer.self_closing("img") 123 | assert_equal("", writer.as_string()) 124 | 125 | 126 | def test_list_item_outside_of_list_is_treated_as_unordered_list(): 127 | writer = _create_writer() 128 | writer.start("li") 129 | writer.text("Fruit") 130 | writer.end("li") 131 | assert_equal("- Fruit\n", writer.as_string()) 132 | 133 | 134 | def test_ol_element_is_written_as_ordered_list_with_sequential_numbering(): 135 | writer = _create_writer() 136 | writer.start("ol") 137 | writer.start("li") 138 | writer.text("Fruit") 139 | writer.end("li") 140 | writer.start("li") 141 | writer.text("Condiments") 142 | writer.end("li") 143 | writer.end("ol") 144 | assert_equal("1. Fruit\n2. Condiments\n\n", writer.as_string()) 145 | 146 | 147 | def test_ul_element_is_written_as_unordered_list_using_hyphens_as_bullets(): 148 | writer = _create_writer() 149 | writer.start("ul") 150 | writer.start("li") 151 | writer.text("Fruit") 152 | writer.end("li") 153 | writer.start("li") 154 | writer.text("Condiments") 155 | writer.end("li") 156 | writer.end("ul") 157 | assert_equal("- Fruit\n- Condiments\n\n", writer.as_string()) 158 | 159 | 160 | def test_numbering_is_separate_for_nested_list_and_parent_list(): 161 | writer = _create_writer() 162 | writer.start("ol") 163 | 164 | writer.start("li") 165 | writer.text("Fruit") 166 | writer.start("ol") 167 | writer.start("li") 168 | writer.text("Apple") 169 | writer.end("li") 170 | writer.start("li") 171 | writer.text("Banana") 172 | writer.end("li") 173 | writer.end("ol") 174 | writer.end("li") 175 | 176 | writer.start("li") 177 | writer.text("Condiments") 178 | writer.end("li") 179 | writer.end("ol") 180 | assert_equal("1. Fruit\n\t1. Apple\n\t2. Banana\n2. Condiments\n\n", writer.as_string()) 181 | 182 | 183 | 184 | def _create_writer(): 185 | return MarkdownWriter() 186 | -------------------------------------------------------------------------------- /mammoth/writers/markdown.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from .abc import Writer 4 | 5 | import re 6 | 7 | 8 | class _WriterOutput(object): 9 | def __init__(self, start, end=None, generate_end=None, anchor_position=None): 10 | if generate_end is None: 11 | generate_end = _constant(end) 12 | 13 | self.start = start 14 | self.generate_end = generate_end 15 | self.anchor_position = anchor_position 16 | 17 | 18 | def _constant(value): 19 | def get(): 20 | return value 21 | 22 | return get 23 | 24 | 25 | class _MarkdownState(object): 26 | def __init__(self): 27 | self._list_state_stack = [] 28 | self.list_state = None 29 | self.list_item_has_closed = False 30 | 31 | def update_list_state(self, list_state): 32 | self._list_state_stack.append(self.list_state) 33 | self.list_state = list_state 34 | 35 | def pop_list_state(self): 36 | self.list_state = self._list_state_stack.pop() 37 | 38 | 39 | class _MarkdownListState(object): 40 | def __init__(self, ordered, indentation): 41 | self.ordered = ordered 42 | self.count = 0 43 | self.indentation = indentation 44 | 45 | 46 | def _symmetric_wrapped(end): 47 | return _Wrapped(end, end) 48 | 49 | 50 | class _Wrapped(object): 51 | def __init__(self, start, end): 52 | self._start = start 53 | self._end = end 54 | 55 | def __call__(self, attributes, markdown_state): 56 | return _WriterOutput(self._start, self._end) 57 | 58 | 59 | def _hyperlink(attributes, markdown_state): 60 | href = attributes.get("href", "") 61 | if href: 62 | return _WriterOutput( 63 | "[", "]({0})".format(href), 64 | anchor_position="before", 65 | ) 66 | else: 67 | return _default_output 68 | 69 | 70 | def _image(attributes, markdown_state): 71 | src = attributes.get("src", "") 72 | alt_text = attributes.get("alt", "") 73 | if src or alt_text: 74 | return _WriterOutput("![{0}]({1})".format(alt_text, src), "") 75 | else: 76 | return _default_output 77 | 78 | 79 | def _list(ordered): 80 | def call(attributes, markdown_state): 81 | if markdown_state.list_state is None: 82 | start = "" 83 | end_text = "\n" 84 | indentation = 0 85 | else: 86 | start = "\n" 87 | end_text = "" 88 | indentation = markdown_state.list_state.indentation + 1 89 | 90 | def generate_end(): 91 | markdown_state.pop_list_state() 92 | return end_text 93 | 94 | markdown_state.update_list_state(_MarkdownListState( 95 | ordered=ordered, 96 | indentation=indentation, 97 | )) 98 | 99 | return _WriterOutput(start, generate_end=generate_end) 100 | 101 | return call 102 | 103 | 104 | def _list_item(attributes, markdown_state): 105 | markdown_state.list_item_has_closed = False 106 | 107 | list_state = markdown_state.list_state or _MarkdownListState(ordered=False, indentation=0) 108 | list_state.count += 1 109 | 110 | if list_state.ordered: 111 | bullet = "{0}.".format(list_state.count) 112 | else: 113 | bullet = "-" 114 | 115 | def generate_end(): 116 | if markdown_state.list_item_has_closed: 117 | return "" 118 | else: 119 | markdown_state.list_item_has_closed = True 120 | return "\n" 121 | 122 | return _WriterOutput( 123 | start=("\t" * list_state.indentation) + bullet + " ", 124 | generate_end=generate_end 125 | ) 126 | 127 | 128 | def _init_writers(): 129 | writers = { 130 | "p": _Wrapped("", "\n\n"), 131 | "br": _Wrapped("", " \n"), 132 | "strong": _symmetric_wrapped("__"), 133 | "em": _symmetric_wrapped("*"), 134 | "a": _hyperlink, 135 | "img": _image, 136 | "ol": _list(ordered=True), 137 | "ul": _list(ordered=False), 138 | "li": _list_item, 139 | } 140 | 141 | for level in range(1, 7): 142 | writers["h{0}".format(level)] = _Wrapped("#" * level + " ", "\n\n") 143 | 144 | return writers 145 | 146 | 147 | _writers = _init_writers() 148 | _default_output = _WriterOutput("", "") 149 | 150 | def _default_writer(attributes, markdown_state): 151 | return _default_output 152 | 153 | 154 | class MarkdownWriter(Writer): 155 | def __init__(self): 156 | self._fragments = [] 157 | self._element_stack = [] 158 | self._markdown_state = _MarkdownState() 159 | 160 | def text(self, text): 161 | self._fragments.append(_escape_markdown(text)) 162 | 163 | def start(self, name, attributes=None): 164 | if attributes is None: 165 | attributes = {} 166 | 167 | output = _writers.get(name, _default_writer)(attributes, self._markdown_state) 168 | self._element_stack.append(output.generate_end) 169 | 170 | anchor_before_start = output.anchor_position == "before" 171 | if anchor_before_start: 172 | self._write_anchor(attributes) 173 | 174 | self._fragments.append(output.start) 175 | 176 | if not anchor_before_start: 177 | self._write_anchor(attributes) 178 | 179 | 180 | 181 | def end(self, name): 182 | end = self._element_stack.pop() 183 | output = end() 184 | self._fragments.append(output) 185 | 186 | def self_closing(self, name, attributes=None): 187 | self.start(name, attributes) 188 | self.end(name) 189 | 190 | def append(self, other): 191 | self._fragments.append(other) 192 | 193 | def as_string(self): 194 | return "".join(self._fragments) 195 | 196 | def _write_anchor(self, attributes): 197 | html_id = attributes.get("id") 198 | if html_id: 199 | self._fragments.append(''.format(html_id)) 200 | 201 | 202 | def _escape_markdown(value): 203 | return re.sub(r"([\`\*_\{\}\[\]\(\)\#\+\-\.\!])", r"\\\1", re.sub("\\\\", "\\\\\\\\", value)) 204 | -------------------------------------------------------------------------------- /mammoth/docx/__init__.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | import os 3 | 4 | import cobble 5 | 6 | from .. import results, lists, zips 7 | from .document_xml import read_document_xml_element 8 | from .content_types_xml import empty_content_types, read_content_types_xml_element 9 | from .relationships_xml import read_relationships_xml_element, Relationships 10 | from .numbering_xml import read_numbering_xml_element, Numbering 11 | from .styles_xml import read_styles_xml_element, Styles 12 | from .notes_xml import read_endnotes_xml_element, read_footnotes_xml_element 13 | from .comments_xml import read_comments_xml_element 14 | from .files import Files 15 | from . import body_xml, office_xml 16 | from ..zips import open_zip 17 | 18 | 19 | _empty_result = results.success([]) 20 | 21 | 22 | def read(fileobj, external_file_access=False): 23 | zip_file = open_zip(fileobj, "r") 24 | part_paths = _find_part_paths(zip_file) 25 | read_part_with_body = _part_with_body_reader( 26 | getattr(fileobj, "name", None), 27 | zip_file, 28 | part_paths=part_paths, 29 | external_file_access=external_file_access, 30 | ) 31 | 32 | return results.combine([ 33 | _read_notes(read_part_with_body, part_paths), 34 | _read_comments(read_part_with_body, part_paths), 35 | ]).bind(lambda referents: 36 | _read_document(zip_file, read_part_with_body, notes=referents[0], comments=referents[1], part_paths=part_paths) 37 | ) 38 | 39 | 40 | @cobble.data 41 | class _PartPaths(object): 42 | main_document = cobble.field() 43 | comments = cobble.field() 44 | endnotes = cobble.field() 45 | footnotes = cobble.field() 46 | numbering = cobble.field() 47 | styles = cobble.field() 48 | 49 | 50 | def _find_part_paths(zip_file): 51 | package_relationships = _read_relationships(zip_file, "_rels/.rels") 52 | document_filename = _find_document_filename(zip_file, package_relationships) 53 | 54 | document_relationships = _read_relationships( 55 | zip_file, 56 | _find_relationships_path_for(document_filename), 57 | ) 58 | 59 | def find(name): 60 | return _find_part_path( 61 | zip_file=zip_file, 62 | relationships=document_relationships, 63 | relationship_type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/" + name, 64 | fallback_path="word/{0}.xml".format(name), 65 | base_path=zips.split_path(document_filename)[0], 66 | ) 67 | 68 | return _PartPaths( 69 | main_document=document_filename, 70 | comments=find("comments"), 71 | endnotes=find("endnotes"), 72 | footnotes=find("footnotes"), 73 | numbering=find("numbering"), 74 | styles=find("styles"), 75 | ) 76 | 77 | 78 | def _find_document_filename(zip_file, relationships): 79 | path = _find_part_path( 80 | zip_file, 81 | relationships, 82 | relationship_type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", 83 | base_path="", 84 | fallback_path="word/document.xml", 85 | ) 86 | if zip_file.exists(path): 87 | return path 88 | else: 89 | raise IOError("Could not find main document part. Are you sure this is a valid .docx file?") 90 | 91 | 92 | def _find_part_path(zip_file, relationships, relationship_type, base_path, fallback_path): 93 | targets = [ 94 | zips.join_path(base_path, target).lstrip("/") 95 | for target in relationships.find_targets_by_type(relationship_type) 96 | ] 97 | valid_targets = list(filter(lambda target: zip_file.exists(target), targets)) 98 | if len(valid_targets) == 0: 99 | return fallback_path 100 | else: 101 | return valid_targets[0] 102 | 103 | 104 | def _read_notes(read_part_with_body, part_paths): 105 | footnotes = read_part_with_body( 106 | part_paths.footnotes, 107 | lambda root, body_reader: read_footnotes_xml_element(root, body_reader=body_reader), 108 | default=_empty_result, 109 | ) 110 | endnotes = read_part_with_body( 111 | part_paths.endnotes, 112 | lambda root, body_reader: read_endnotes_xml_element(root, body_reader=body_reader), 113 | default=_empty_result, 114 | ) 115 | 116 | return results.combine([footnotes, endnotes]).map(lists.flatten) 117 | 118 | 119 | def _read_comments(read_part_with_body, part_paths): 120 | return read_part_with_body( 121 | part_paths.comments, 122 | lambda root, body_reader: read_comments_xml_element(root, body_reader=body_reader), 123 | default=_empty_result, 124 | ) 125 | 126 | 127 | def _read_document(zip_file, read_part_with_body, notes, comments, part_paths): 128 | return read_part_with_body( 129 | part_paths.main_document, 130 | partial( 131 | read_document_xml_element, 132 | notes=notes, 133 | comments=comments, 134 | ), 135 | ) 136 | 137 | 138 | def _part_with_body_reader(document_path, zip_file, part_paths, external_file_access): 139 | content_types = _try_read_entry_or_default( 140 | zip_file, 141 | "[Content_Types].xml", 142 | read_content_types_xml_element, 143 | empty_content_types, 144 | ) 145 | 146 | styles = _try_read_entry_or_default( 147 | zip_file, 148 | part_paths.styles, 149 | read_styles_xml_element, 150 | Styles.EMPTY, 151 | ) 152 | 153 | numbering = _try_read_entry_or_default( 154 | zip_file, 155 | part_paths.numbering, 156 | lambda element: read_numbering_xml_element(element, styles=styles), 157 | default=Numbering.EMPTY, 158 | ) 159 | 160 | files = Files( 161 | None if document_path is None else os.path.dirname(document_path), 162 | external_file_access=external_file_access, 163 | ) 164 | 165 | def read_part(name, reader, default=_undefined): 166 | relationships = _read_relationships(zip_file, _find_relationships_path_for(name)) 167 | 168 | body_reader = body_xml.reader( 169 | numbering=numbering, 170 | content_types=content_types, 171 | relationships=relationships, 172 | styles=styles, 173 | docx_file=zip_file, 174 | files=files, 175 | ) 176 | 177 | if default is _undefined: 178 | return _read_entry(zip_file, name, partial(reader, body_reader=body_reader)) 179 | else: 180 | return _try_read_entry_or_default(zip_file, name, partial(reader, body_reader=body_reader), default=default) 181 | 182 | return read_part 183 | 184 | 185 | 186 | def _find_relationships_path_for(name): 187 | dirname, basename = zips.split_path(name) 188 | return zips.join_path(dirname, "_rels", basename + ".rels") 189 | 190 | 191 | def _read_relationships(zip_file, name): 192 | return _try_read_entry_or_default( 193 | zip_file, 194 | name, 195 | read_relationships_xml_element, 196 | default=Relationships.EMPTY, 197 | ) 198 | 199 | def _try_read_entry_or_default(zip_file, name, reader, default): 200 | if zip_file.exists(name): 201 | return _read_entry(zip_file, name, reader) 202 | else: 203 | return default 204 | 205 | 206 | def _read_entry(zip_file, name, reader): 207 | with zip_file.open(name) as fileobj: 208 | return reader(office_xml.read(fileobj)) 209 | 210 | 211 | _undefined = object() 212 | -------------------------------------------------------------------------------- /tests/docx/numbering_xml_tests.py: -------------------------------------------------------------------------------- 1 | from mammoth.docx.xmlparser import element as xml_element 2 | from mammoth.docx.numbering_xml import read_numbering_xml_element 3 | from mammoth.docx.styles_xml import NumberingStyle, Styles 4 | from ..testing import assert_equal 5 | 6 | 7 | def test_find_level_returns_none_if_num_with_id_cannot_be_found(): 8 | numbering = _read_numbering_xml_element(xml_element("w:numbering")) 9 | assert_equal(None, numbering.find_level("47", "0")) 10 | 11 | 12 | _sample_numbering_xml = xml_element("w:numbering", {}, [ 13 | xml_element("w:abstractNum", {"w:abstractNumId": "42"}, [ 14 | xml_element("w:lvl", {"w:ilvl": "0"}, [ 15 | xml_element("w:numFmt", {"w:val": "bullet"}) 16 | ]), 17 | xml_element("w:lvl", {"w:ilvl": "1"}, [ 18 | xml_element("w:numFmt", {"w:val": "decimal"}) 19 | ]) 20 | ]), 21 | xml_element("w:num", {"w:numId": "47"}, [ 22 | xml_element("w:abstractNumId", {"w:val": "42"}) 23 | ]) 24 | ]) 25 | 26 | 27 | def test_level_includes_level_index(): 28 | numbering = _read_numbering_xml_element(_sample_numbering_xml) 29 | assert_equal("0", numbering.find_level("47", "0").level_index) 30 | assert_equal("1", numbering.find_level("47", "1").level_index) 31 | 32 | 33 | def test_list_is_not_ordered_if_formatted_as_bullet(): 34 | numbering = _read_numbering_xml_element(_sample_numbering_xml) 35 | assert_equal(False, numbering.find_level("47", "0").is_ordered) 36 | 37 | 38 | def test_list_is_ordered_if_formatted_as_decimal(): 39 | numbering = _read_numbering_xml_element(_sample_numbering_xml) 40 | assert_equal(True, numbering.find_level("47", "1").is_ordered) 41 | 42 | 43 | def test_list_is_ordered_if_there_is_no_explicit_format(): 44 | element = xml_element("w:numbering", {}, [ 45 | xml_element("w:abstractNum", {"w:abstractNumId": "42"}, [ 46 | xml_element("w:lvl", {"w:ilvl": "0"}), 47 | ]), 48 | xml_element("w:num", {"w:numId": "47"}, [ 49 | xml_element("w:abstractNumId", {"w:val": "42"}) 50 | ]) 51 | ]) 52 | 53 | numbering = _read_numbering_xml_element(element) 54 | 55 | assert_equal(True, numbering.find_level("47", "0").is_ordered) 56 | 57 | 58 | def test_find_level_returns_none_if_level_cannot_be_found(): 59 | numbering = _read_numbering_xml_element(_sample_numbering_xml) 60 | assert_equal(None, numbering.find_level("47", "2")) 61 | 62 | 63 | def test_num_referencing_non_existent_abstract_num_is_ignored(): 64 | element = xml_element("w:numbering", {}, [ 65 | xml_element("w:num", {"w:numId": "47"}, [ 66 | xml_element("w:abstractNumId", {"w:val": "42"}) 67 | ]) 68 | ]) 69 | 70 | numbering = _read_numbering_xml_element(element) 71 | 72 | assert_equal(None, numbering.find_level("47", "0")) 73 | 74 | 75 | def test_given_no_other_levels_with_index_of_0_when_level_is_missing_ilvl_then_level_index_is_0(): 76 | element = xml_element("w:numbering", {}, [ 77 | xml_element("w:abstractNum", {"w:abstractNumId": "42"}, [ 78 | xml_element("w:lvl", {}, [ 79 | xml_element("w:numFmt", {"w:val": "decimal"}), 80 | ]), 81 | ]), 82 | xml_element("w:num", {"w:numId": "47"}, [ 83 | xml_element("w:abstractNumId", {"w:val": "42"}) 84 | ]) 85 | ]) 86 | 87 | numbering = _read_numbering_xml_element(element) 88 | 89 | assert_equal(True, numbering.find_level("47", "0").is_ordered) 90 | 91 | 92 | def test_given_previous_other_level_with_index_of_0_when_level_is_missing_ilvl_then_level_is_ignored(): 93 | element = xml_element("w:numbering", {}, [ 94 | xml_element("w:abstractNum", {"w:abstractNumId": "42"}, [ 95 | xml_element("w:lvl", {"w:ilvl": "0"}, [ 96 | xml_element("w:numFmt", {"w:val": "bullet"}), 97 | ]), 98 | xml_element("w:lvl", {}, [ 99 | xml_element("w:numFmt", {"w:val": "decimal"}), 100 | ]), 101 | ]), 102 | xml_element("w:num", {"w:numId": "47"}, [ 103 | xml_element("w:abstractNumId", {"w:val": "42"}) 104 | ]) 105 | ]) 106 | 107 | numbering = _read_numbering_xml_element(element) 108 | 109 | assert_equal(False, numbering.find_level("47", "0").is_ordered) 110 | 111 | 112 | def test_given_subsequent_other_level_with_index_of_0_when_level_is_missing_ilvl_then_level_is_ignored(): 113 | element = xml_element("w:numbering", {}, [ 114 | xml_element("w:abstractNum", {"w:abstractNumId": "42"}, [ 115 | xml_element("w:lvl", {}, [ 116 | xml_element("w:numFmt", {"w:val": "decimal"}), 117 | ]), 118 | xml_element("w:lvl", {"w:ilvl": "0"}, [ 119 | xml_element("w:numFmt", {"w:val": "bullet"}), 120 | ]), 121 | ]), 122 | xml_element("w:num", {"w:numId": "47"}, [ 123 | xml_element("w:abstractNumId", {"w:val": "42"}) 124 | ]) 125 | ]) 126 | 127 | numbering = _read_numbering_xml_element(element) 128 | 129 | assert_equal(False, numbering.find_level("47", "0").is_ordered) 130 | 131 | 132 | def test_when_abstract_num_has_num_style_link_then_style_is_used_to_find_num(): 133 | numbering = _read_numbering_xml_element( 134 | xml_element("w:numbering", {}, [ 135 | xml_element("w:abstractNum", {"w:abstractNumId": "100"}, [ 136 | xml_element("w:lvl", {"w:ilvl": "0"}, [ 137 | xml_element("w:numFmt", {"w:val": "decimal"}), 138 | ]), 139 | ]), 140 | xml_element("w:abstractNum", {"w:abstractNumId": "101"}, [ 141 | xml_element("w:numStyleLink", {"w:val": "List1"}), 142 | ]), 143 | xml_element("w:num", {"w:numId": "200"}, [ 144 | xml_element("w:abstractNumId", {"w:val": "100"}), 145 | ]), 146 | xml_element("w:num", {"w:numId": "201"}, [ 147 | xml_element("w:abstractNumId", {"w:val": "101"}), 148 | ]) 149 | ]), 150 | styles=Styles.create(numbering_styles={ 151 | "List1": NumberingStyle(style_id="List1", num_id="200"), 152 | }), 153 | ) 154 | assert_equal(True, numbering.find_level("201", "0").is_ordered) 155 | 156 | 157 | # See: 17.9.23 pStyle (Paragraph Style's Associated Numbering Level) in ECMA-376, 4th Edition 158 | def test_numbering_level_can_be_found_by_paragraph_style_id(): 159 | numbering = _read_numbering_xml_element( 160 | xml_element("w:numbering", {}, [ 161 | xml_element("w:abstractNum", {"w:abstractNumId": "42"}, [ 162 | xml_element("w:lvl", {"w:ilvl": "0"}, [ 163 | xml_element("w:numFmt", {"w:val": "bullet"}), 164 | ]), 165 | ]), 166 | xml_element("w:abstractNum", {"w:abstractNumId": "43"}, [ 167 | xml_element("w:lvl", {"w:ilvl": "0"}, [ 168 | xml_element("w:pStyle", {"w:val": "List"}), 169 | xml_element("w:numFmt", {"w:val": "decimal"}), 170 | ]), 171 | ]), 172 | ]), 173 | ) 174 | 175 | assert_equal(True, numbering.find_level_by_paragraph_style_id("List").is_ordered) 176 | assert_equal(None, numbering.find_level_by_paragraph_style_id("Paragraph")) 177 | 178 | 179 | def _read_numbering_xml_element(element, styles=None): 180 | if styles is None: 181 | styles = Styles.EMPTY 182 | 183 | return read_numbering_xml_element(element, styles=styles) 184 | -------------------------------------------------------------------------------- /mammoth/documents.py: -------------------------------------------------------------------------------- 1 | import cobble 2 | 3 | 4 | class Element(object): 5 | def copy(self, **kwargs): 6 | return cobble.copy(self, **kwargs) 7 | 8 | 9 | class HasChildren(Element): 10 | children = cobble.field() 11 | 12 | 13 | @cobble.data 14 | class Document(HasChildren): 15 | notes = cobble.field() 16 | comments = cobble.field() 17 | 18 | @cobble.data 19 | class Paragraph(HasChildren): 20 | style_id = cobble.field() 21 | style_name = cobble.field() 22 | numbering = cobble.field() 23 | alignment = cobble.field() 24 | indent = cobble.field() 25 | 26 | 27 | @cobble.data 28 | class ParagraphIndent(object): 29 | start = cobble.field() 30 | end = cobble.field() 31 | first_line = cobble.field() 32 | hanging = cobble.field() 33 | 34 | 35 | @cobble.data 36 | class Indent(object): 37 | left = cobble.field() 38 | right = cobble.field() 39 | first_line = cobble.field() 40 | hanging = cobble.field() 41 | 42 | 43 | @cobble.data 44 | class Run(HasChildren): 45 | style_id = cobble.field() 46 | style_name = cobble.field() 47 | is_bold = cobble.field() 48 | is_italic = cobble.field() 49 | is_underline = cobble.field() 50 | is_strikethrough = cobble.field() 51 | is_all_caps = cobble.field() 52 | is_small_caps = cobble.field() 53 | vertical_alignment = cobble.field() 54 | font = cobble.field() 55 | font_size = cobble.field() 56 | highlight = cobble.field() 57 | 58 | @cobble.data 59 | class Text(Element): 60 | value = cobble.field() 61 | 62 | @cobble.data 63 | class Hyperlink(HasChildren): 64 | href = cobble.field() 65 | anchor = cobble.field() 66 | target_frame = cobble.field() 67 | 68 | @cobble.data 69 | class Checkbox(Element): 70 | checked = cobble.field() 71 | 72 | checkbox = Checkbox 73 | 74 | @cobble.data 75 | class Table(HasChildren): 76 | style_id = cobble.field() 77 | style_name = cobble.field() 78 | 79 | @cobble.data 80 | class TableRow(HasChildren): 81 | is_header = cobble.field() 82 | 83 | @cobble.data 84 | class TableCell(HasChildren): 85 | colspan = cobble.field() 86 | rowspan = cobble.field() 87 | 88 | @cobble.data 89 | class TableCellUnmerged: 90 | children = cobble.field() 91 | colspan = cobble.field() 92 | rowspan = cobble.field() 93 | vmerge = cobble.field() 94 | 95 | def _accept1(self, visitor, arg0): 96 | return visitor.visit_table_cell(self, arg0) 97 | 98 | def copy(self, **kwargs): 99 | return cobble.copy(self, **kwargs) 100 | 101 | @cobble.data 102 | class Break(Element): 103 | break_type = cobble.field() 104 | 105 | line_break = Break("line") 106 | page_break = Break("page") 107 | column_break = Break("column") 108 | 109 | 110 | @cobble.data 111 | class Tab(Element): 112 | pass 113 | 114 | 115 | @cobble.data 116 | class Image(Element): 117 | alt_text = cobble.field() 118 | content_type = cobble.field() 119 | open = cobble.field() 120 | 121 | 122 | def document(children, notes=None, comments=None): 123 | if notes is None: 124 | notes = Notes({}) 125 | if comments is None: 126 | comments = [] 127 | return Document(children, notes, comments=comments) 128 | 129 | def paragraph(children, style_id=None, style_name=None, numbering=None, alignment=None, indent=None): 130 | if indent is None: 131 | indent = paragraph_indent() 132 | 133 | return Paragraph(children, style_id, style_name, numbering, alignment=alignment, indent=indent) 134 | 135 | def paragraph_indent(start=None, end=None, first_line=None, hanging=None): 136 | return ParagraphIndent(start=start, end=end, first_line=first_line, hanging=hanging) 137 | 138 | def run( 139 | children, 140 | style_id=None, 141 | style_name=None, 142 | is_bold=None, 143 | is_italic=None, 144 | is_underline=None, 145 | is_strikethrough=None, 146 | is_all_caps=None, 147 | is_small_caps=None, 148 | vertical_alignment=None, 149 | font=None, 150 | font_size=None, 151 | highlight=None, 152 | ): 153 | if vertical_alignment is None: 154 | vertical_alignment = VerticalAlignment.baseline 155 | return Run( 156 | children=children, 157 | style_id=style_id, 158 | style_name=style_name, 159 | is_bold=bool(is_bold), 160 | is_italic=bool(is_italic), 161 | is_underline=bool(is_underline), 162 | is_strikethrough=bool(is_strikethrough), 163 | is_all_caps=bool(is_all_caps), 164 | is_small_caps=bool(is_small_caps), 165 | vertical_alignment=vertical_alignment, 166 | font=font, 167 | font_size=font_size, 168 | highlight=highlight, 169 | ) 170 | 171 | class VerticalAlignment(object): 172 | baseline = "baseline" 173 | superscript = "superscript" 174 | subscript = "subscript" 175 | 176 | text = Text 177 | 178 | _tab = Tab() 179 | 180 | def tab(): 181 | return _tab 182 | 183 | 184 | image = Image 185 | 186 | def hyperlink(children, href=None, anchor=None, target_frame=None): 187 | return Hyperlink(href=href, anchor=anchor, target_frame=target_frame, children=children) 188 | 189 | 190 | @cobble.data 191 | class Bookmark(Element): 192 | name = cobble.field() 193 | 194 | bookmark = Bookmark 195 | 196 | 197 | def table(children, style_id=None, style_name=None): 198 | return Table(children=children, style_id=style_id, style_name=style_name) 199 | 200 | def table_row(children, is_header=None): 201 | return TableRow(children=children, is_header=bool(is_header)) 202 | 203 | def table_cell(children, colspan=None, rowspan=None): 204 | if colspan is None: 205 | colspan = 1 206 | if rowspan is None: 207 | rowspan = 1 208 | return TableCell(children=children, colspan=colspan, rowspan=rowspan) 209 | 210 | def table_cell_unmerged(children, colspan, rowspan, vmerge): 211 | return TableCellUnmerged(children=children, colspan=colspan, rowspan=rowspan, vmerge=vmerge) 212 | 213 | def numbering_level(level_index, is_ordered): 214 | return _NumberingLevel(str(level_index), bool(is_ordered)) 215 | 216 | @cobble.data 217 | class _NumberingLevel(object): 218 | level_index = cobble.field() 219 | is_ordered = cobble.field() 220 | 221 | @cobble.data 222 | class Note(Element): 223 | note_type = cobble.field() 224 | note_id = cobble.field() 225 | body = cobble.field() 226 | 227 | 228 | note = Note 229 | 230 | 231 | class Notes(object): 232 | def __init__(self, notes): 233 | self._notes = notes 234 | 235 | def find_note(self, note_type, note_id): 236 | return self._notes[(note_type, note_id)] 237 | 238 | def resolve(self, reference): 239 | return self.find_note(reference.note_type, reference.note_id) 240 | 241 | def __eq__(self, other): 242 | return isinstance(other, Notes) and self._notes == other._notes 243 | 244 | def __ne__(self, other): 245 | return not (self == other) 246 | 247 | def notes(notes_list): 248 | return Notes(dict( 249 | (_note_key(note), note) 250 | for note in notes_list 251 | )) 252 | 253 | def _note_key(note): 254 | return (note.note_type, note.note_id) 255 | 256 | @cobble.data 257 | class NoteReference(Element): 258 | note_type = cobble.field() 259 | note_id = cobble.field() 260 | 261 | note_reference = NoteReference 262 | 263 | 264 | @cobble.data 265 | class Comment(object): 266 | comment_id = cobble.field() 267 | body = cobble.field() 268 | author_name = cobble.field() 269 | author_initials = cobble.field() 270 | 271 | def comment(comment_id, body, author_name=None, author_initials=None): 272 | return Comment( 273 | comment_id=comment_id, 274 | body=body, 275 | author_name=author_name, 276 | author_initials=author_initials, 277 | ) 278 | 279 | @cobble.data 280 | class CommentReference(Element): 281 | comment_id = cobble.field() 282 | 283 | comment_reference = CommentReference 284 | 285 | def element_visitor(args): 286 | return cobble.visitor(Element, args=args) 287 | -------------------------------------------------------------------------------- /tests/docx/docx_tests.py: -------------------------------------------------------------------------------- 1 | import io 2 | import textwrap 3 | import zipfile 4 | 5 | from mammoth import docx, documents, zips 6 | from ..testing import assert_equal, assert_raises, generate_test_path 7 | 8 | 9 | class ReadTests(object): 10 | def test_can_read_document_with_single_paragraph_with_single_run_of_text(self): 11 | with open(generate_test_path("single-paragraph.docx"), "rb") as fileobj: 12 | result = docx.read(fileobj=fileobj) 13 | expected_document = documents.document([ 14 | documents.paragraph([ 15 | documents.run([ 16 | documents.text("Walking on imported air") 17 | ]) 18 | ]) 19 | ]) 20 | assert_equal(expected_document, result.value) 21 | 22 | 23 | _relationship_namespaces = { 24 | "r": "http://schemas.openxmlformats.org/package/2006/relationships", 25 | } 26 | 27 | 28 | def test_main_document_is_found_using_package_relationships(): 29 | fileobj = _create_zip({ 30 | "word/document2.xml": textwrap.dedent("""\ 31 | 32 | 33 | 34 | 35 | 36 | Hello. 37 | 38 | 39 | 40 | 41 | """), 42 | "_rels/.rels": textwrap.dedent("""\ 43 | 44 | 45 | 46 | 47 | """), 48 | }) 49 | result = docx.read(fileobj=fileobj) 50 | expected_document = documents.document([ 51 | documents.paragraph([ 52 | documents.run([ 53 | documents.text("Hello.") 54 | ]) 55 | ]) 56 | ]) 57 | assert_equal(expected_document, result.value) 58 | 59 | 60 | def test_error_is_raised_when_main_document_part_does_not_exist(): 61 | fileobj = _create_zip({ 62 | "_rels/.rels": textwrap.dedent("""\ 63 | 64 | 65 | 66 | 67 | """), 68 | }) 69 | error = assert_raises(IOError, lambda: docx.read(fileobj=fileobj)) 70 | assert_equal( 71 | "Could not find main document part. Are you sure this is a valid .docx file?", 72 | str(error), 73 | ) 74 | 75 | class PartPathsTests(object): 76 | def test_main_document_part_is_found_using_package_relationships(self): 77 | fileobj = _create_zip({ 78 | "word/document2.xml": " ", 79 | "_rels/.rels": textwrap.dedent("""\ 80 | 81 | 82 | 83 | 84 | """), 85 | }) 86 | part_paths = self._find_part_paths(fileobj) 87 | assert_equal("word/document2.xml", part_paths.main_document) 88 | 89 | def test_when_relationship_for_main_document_cannot_be_found_then_fallback_is_used(self): 90 | fileobj = _create_zip({ 91 | "word/document.xml": " ", 92 | }) 93 | part_paths = self._find_part_paths(fileobj) 94 | assert_equal("word/document.xml", part_paths.main_document) 95 | 96 | def test_comments_part_is_found_using_main_document_relationships(self): 97 | self._assert_path_is_found_using_main_document_relationships("comments") 98 | 99 | def test_when_relationship_for_comments_cannot_be_found_then_fallback_is_used(self): 100 | self._assert_when_relationship_for_part_cannot_be_found_then_fallback_is_used("comments") 101 | 102 | def test_endnotes_part_is_found_using_main_document_relationships(self): 103 | self._assert_path_is_found_using_main_document_relationships("endnotes") 104 | 105 | def test_when_relationship_for_endnotes_cannot_be_found_then_fallback_is_used(self): 106 | self._assert_when_relationship_for_part_cannot_be_found_then_fallback_is_used("endnotes") 107 | 108 | def test_footnotes_part_is_found_using_main_document_relationships(self): 109 | self._assert_path_is_found_using_main_document_relationships("footnotes") 110 | 111 | def test_when_relationship_for_footnotes_cannot_be_found_then_fallback_is_used(self): 112 | self._assert_when_relationship_for_part_cannot_be_found_then_fallback_is_used("footnotes") 113 | 114 | def test_numbering_part_is_found_using_main_document_relationships(self): 115 | self._assert_path_is_found_using_main_document_relationships("numbering") 116 | 117 | def test_when_relationship_for_numbering_cannot_be_found_then_fallback_is_used(self): 118 | self._assert_when_relationship_for_part_cannot_be_found_then_fallback_is_used("numbering") 119 | 120 | def test_styles_part_is_found_using_main_document_relationships(self): 121 | self._assert_path_is_found_using_main_document_relationships("styles") 122 | 123 | def test_when_relationship_for_styles_cannot_be_found_then_fallback_is_used(self): 124 | self._assert_when_relationship_for_part_cannot_be_found_then_fallback_is_used("styles") 125 | 126 | def _assert_path_is_found_using_main_document_relationships(self, name): 127 | fileobj = _create_zip({ 128 | "_rels/.rels": textwrap.dedent("""\ 129 | 130 | 131 | 132 | 133 | """), 134 | "word/document.xml": " ", 135 | "word/_rels/document.xml.rels": textwrap.dedent("""\ 136 | 137 | 138 | 139 | 140 | """.format(name=name)), 141 | "word/target-path.xml": " " 142 | }) 143 | part_paths = self._find_part_paths(fileobj) 144 | assert_equal("word/target-path.xml", getattr(part_paths, name)) 145 | 146 | def _assert_when_relationship_for_part_cannot_be_found_then_fallback_is_used(self, name): 147 | fileobj = _create_zip({ 148 | "_rels/.rels": textwrap.dedent("""\ 149 | 150 | 151 | 152 | 153 | """), 154 | "word/document.xml": " ", 155 | }) 156 | part_paths = self._find_part_paths(fileobj) 157 | assert_equal("word/{0}.xml".format(name), getattr(part_paths, name)) 158 | 159 | 160 | def _find_part_paths(self, fileobj): 161 | return docx._find_part_paths(zips.open_zip(fileobj, "r")) 162 | 163 | 164 | def _create_zip(files): 165 | fileobj = io.BytesIO() 166 | 167 | zip_file = zipfile.ZipFile(fileobj, "w") 168 | try: 169 | for name, contents in files.items(): 170 | zip_file.writestr(name, contents) 171 | finally: 172 | zip_file.close() 173 | 174 | fileobj.seek(0) 175 | return fileobj 176 | -------------------------------------------------------------------------------- /NEWS: -------------------------------------------------------------------------------- 1 | # 1.12.0 2 | 3 | * Handle hyperlinked wp:anchor and wp:inline elements. 4 | 5 | # 1.11.0 6 | 7 | * Ignore style definitions using a style ID that has already been used. 8 | 9 | * Fix conversion of unmerged table cells. 10 | 11 | * Disable external file accesses by default. External file access can be enabled 12 | using the external_file_access argument. 13 | 14 | * Handle numbering levels defined without an index. 15 | 16 | # 1.10.0 17 | 18 | * Add "Heading" and "Body" styles, as found in documents created by Apple Pages, 19 | to the default style map. 20 | 21 | * Handle structured document tags representing checkboxes wrapped in other 22 | elements, such as table cells. Previously, the wrapping elements would have 23 | been ignored. 24 | 25 | * Ignore deleted table rows. 26 | 27 | * Add notes on security. 28 | 29 | # 1.9.1 30 | 31 | * Ignore AlternateContent elements when there is no Fallback element. 32 | 33 | # 1.9.0 34 | 35 | * Detect checkboxes, both as complex fields and structured document tags, and 36 | convert them to checkbox inputs. 37 | 38 | * Ignore AlternateContent elements when there is no Fallback element. 39 | 40 | # 1.8.0 41 | 42 | * Add style mapping for highlights. 43 | 44 | # 1.7.1 45 | 46 | * Switch the precedence of numbering properties in paragraph properties and the 47 | numbering in paragraph styles so that the numbering properties in paragraph 48 | properties takes precedence. 49 | 50 | # 1.7.0 51 | 52 | * Support attributes in HTML paths in style mappings. 53 | 54 | * Improve error message when failing to find the body element in a document. 55 | 56 | * Drop support for Python 2.7, Python 3.5 and Python 3.6. 57 | 58 | * Add support for the strict document format. 59 | 60 | # 1.6.0 61 | 62 | * Support merged paragraphs when revisions are tracked. 63 | 64 | # 1.5.1 65 | 66 | * Add a pyproject.toml to add an explicit build dependency on setuptools. 67 | 68 | # 1.5.0 69 | 70 | * Only use the alt text of image elements as a fallback. If an alt attribute is 71 | returned from the function passed to mammoth.images.img_element, that value 72 | will now be preferred to the alt text of the image element. 73 | 74 | # 1.4.19 75 | 76 | * Ignore w:u elements when w:val is missing. 77 | 78 | # 1.4.18 79 | 80 | * Emit warning instead of throwing exception when image file cannot be found for 81 | a:blip elements. 82 | 83 | # 1.4.17 84 | 85 | * When extracting raw text, convert tab elements to tab characters. 86 | 87 | * Handle internal hyperlinks created with complex fields. 88 | 89 | # 1.4.16 90 | 91 | * Handle w:num with invalid w:abstractNumId. 92 | 93 | # 1.4.15 94 | 95 | * Convert symbols in supported fonts to corresponding Unicode characters. 96 | 97 | # 1.4.14 98 | 99 | * Support numbering defined by paragraph style. 100 | 101 | # 1.4.13 102 | 103 | * Add style mapping for all caps. 104 | 105 | # 1.4.12 106 | 107 | * Handle underline elements where w:val is "none". 108 | 109 | # 1.4.11 110 | 111 | * Read font size for runs. 112 | * Support soft hyphens. 113 | 114 | # 1.4.10 115 | 116 | * Update supported Python versions to 2.7 and 3.4 to 3.8. 117 | 118 | # 1.4.9 119 | 120 | * Improve list support by following w:numStyleLink in w:abstractNum. 121 | 122 | # 1.4.8 123 | 124 | * Preserve empty table rows. 125 | 126 | # 1.4.7 127 | 128 | * Always write files as UTF-8 in the CLI. 129 | 130 | # 1.4.6 131 | 132 | * Fix: default style mappings caused footnotes, endnotes and comments 133 | containing multiple paragraphs to be converted into a single paragraph. 134 | 135 | # 1.4.5 136 | 137 | * Read the children of v:rect elements. 138 | 139 | # 1.4.4 140 | 141 | * Parse paragraph indents. 142 | 143 | * Read part paths using relationships. This improves support for documents 144 | created by Word Online. 145 | 146 | # 1.4.3 147 | 148 | * Add style mapping for small caps. 149 | 150 | * Add style mapping for tables. 151 | 152 | # 1.4.2 153 | 154 | * Read children of v:group elements. 155 | 156 | # 1.4.1 157 | 158 | * Read w:noBreakHyphen elements as non-breaking hyphen characters. 159 | 160 | # 1.4.0 161 | 162 | * Extract the default data URI image converter to the images module. 163 | 164 | * Add anchor on hyperlinks as fragment if present. 165 | 166 | * Convert target frames on hyperlinks to targets on anchors. 167 | 168 | * Detect header rows in tables and convert to thead > tr > th. 169 | 170 | # 1.3.5 171 | 172 | * Handle complex fields that do not have a "separate" fldChar. 173 | 174 | # 1.3.4 175 | 176 | * Add transforms.run. 177 | 178 | # 1.3.3 179 | 180 | * Read children of w:object elements. 181 | 182 | * Add support for document transforms. 183 | 184 | # 1.3.2 185 | 186 | * Handle hyperlinks created with complex fields. 187 | 188 | # 1.3.1 189 | 190 | * Handle absolute paths within zip files. This should fix an issue where some 191 | images within a document couldn't be found. 192 | 193 | # 1.3.0 194 | 195 | * Allow style names to be mapped by prefix. For instance: 196 | 197 | r[style-name^='Code '] => code 198 | 199 | * Add default style mappings for Heading 5 and Heading 6. 200 | 201 | * Allow escape sequences in style IDs, style names and CSS class names. 202 | 203 | * Allow a separator to be specified when HTML elements are collapsed. 204 | 205 | * Add include_embedded_style_map argument to allow embedded style maps to be 206 | disabled. 207 | 208 | * Include embedded styles when explicit style map is passed. 209 | 210 | # 1.2.2 211 | 212 | * Ignore bold, italic, underline and strikethrough elements that have a value of 213 | false or 0. 214 | 215 | # 1.2.1 216 | 217 | * Ignore v:imagedata elements without relationship ID with warning. 218 | 219 | # 1.2.0 220 | 221 | * Use alt text title as alt text for images when the alt text description is 222 | blank or missing. 223 | 224 | # 1.1.1 225 | 226 | * Handle comments without author initials. 227 | 228 | * Change numbering of comments to be global rather than per-user to match the 229 | behaviour of Word. 230 | 231 | # 1.1.0 232 | 233 | * Add support for comments. 234 | 235 | # 1.0.4 236 | 237 | * Add support for w:sdt elements. This allows the bodies of content controls, 238 | such as bibliographies, to be converted. 239 | 240 | # 1.0.3 241 | 242 | * Add support for table cells spanning multiple rows. 243 | 244 | # 1.0.2 245 | 246 | * Add support for table cells spanning multiple columns. 247 | 248 | # 1.0.1 249 | 250 | * Improve script installation on Windows by using entry_points instead of 251 | scripts in setup.py. 252 | 253 | # 1.0.0 254 | 255 | * Remove deprecated convert_underline argument. 256 | 257 | * Officially support ID prefixes. 258 | 259 | * Generated IDs no longer insert a hyphen after the ID prefix. 260 | 261 | * The default ID prefix is now the empty string rather than a random number 262 | followed by a hyphen. 263 | 264 | * Rename mammoth.images.inline to mammoth.images.img_element to better reflect 265 | its behaviour. 266 | 267 | # 0.3.31 268 | 269 | * Improve collapsing of similar non-fresh HTML elements. 270 | 271 | # 0.3.30 272 | 273 | * Allow bold and italic style mappings to be configured. 274 | 275 | # 0.3.29 276 | 277 | * Handle references to missing styles when reading documents. 278 | 279 | # 0.3.28 280 | 281 | * Improve support for lists made in LibreOffice. Specifically, this changes the 282 | default style mapping for paragraphs with a style of "Normal" to have the 283 | lowest precedence. 284 | 285 | # 0.3.27 286 | 287 | * Handle XML where the child nodes of an element contains text nodes. 288 | 289 | # 0.3.26 290 | 291 | * Always use mc:Fallback when reading mc:AlternateContent elements. 292 | 293 | # 0.3.25 294 | 295 | * Remove duplicate messages from results. 296 | 297 | * Read v:imagedata with r:id attribute. 298 | 299 | * Read children of v:roundrect. 300 | 301 | * Ignore office-word:wrap, v:shadow and v:shapetype. 302 | 303 | # 0.3.24 304 | 305 | * Continue with warning if external images cannot be found. 306 | 307 | * Add support for embedded style maps. 308 | 309 | # 0.3.23 310 | 311 | * Fix Python 3 support. 312 | 313 | # 0.3.22 314 | 315 | * Generate warnings for not-understood style mappings and continue, rather than 316 | stopping with an error. 317 | 318 | * Support file objects without a name attribute again (broken since 0.3.20). 319 | 320 | # 0.3.21 321 | 322 | * Ignore w:numPr elements without w:numId or w:ilvl children. 323 | 324 | # 0.3.20 325 | 326 | * Add support for linked images. 327 | 328 | # 0.3.19 329 | 330 | * Fix: cannot extract raw text from elements without children 331 | 332 | # 0.3.18 333 | 334 | * Support links and images in footnotes and endnotes. 335 | 336 | # 0.3.17 337 | 338 | * Add support for underlines in style map. 339 | 340 | * Add support for strikethrough. 341 | 342 | # 0.3.16 343 | 344 | * Add basic support for text boxes. The contents of the text box are treated as 345 | a separate paragraph that appears after the paragraph containing the text box. 346 | 347 | # 0.3.15 348 | 349 | * Support styles defined without a name 350 | 351 | # 0.3.14 352 | 353 | * Add ignore_empty_paragraphs option, which defaults to True. 354 | 355 | # 0.3.13 356 | 357 | * Always use forward slashes in ZIP paths. This should fix image handling on 358 | Windows. 359 | 360 | # 0.3.12 361 | 362 | * Make style names case-insensitive in style mappings. This should make style 363 | mappings easier to write, especially since Microsoft Word sometimes represents 364 | style names in the UI differently from in the style definition. For instance, 365 | the style displayed in Word as "Heading 1" has a style name of "heading 1". 366 | This hopefully shouldn't cause an issue for anyone, but if you were relying 367 | on case-sensitivity, please do get in touch. 368 | 369 | # 0.3.11 370 | 371 | * Add support for hyperlinks to bookmarks in the same document. 372 | 373 | # 0.3.10 374 | 375 | * Add basic support for Markdown. Not all features are currently supported. 376 | 377 | # 0.3.9 378 | 379 | * Add default style mappings for builtin footnote and endnote styles in 380 | Microsoft Word and LibreOffice. 381 | 382 | * Allow style mappings with a zero-element HTML path. 383 | 384 | * Emit warnings when image types are unlikely to be supported by web browsers. 385 | 386 | # 0.3.8 387 | 388 | * Add support for endnotes. 389 | 390 | # 0.3.7 391 | 392 | * Add support for superscript and subscript text. 393 | 394 | # 0.3.6 395 | 396 | * Add support for footnotes. 397 | 398 | # 0.3.5 399 | 400 | * Add support for line breaks. 401 | 402 | # 0.3.4 403 | 404 | * Add optional underline conversion. 405 | 406 | # 0.3.3 407 | 408 | * Add `mammoth.images.inline`, and document custom image conversion. 409 | 410 | # 0.3.2 411 | 412 | * Add the function `mammoth.extract_raw_text`. 413 | 414 | # 0.3.1 415 | 416 | * Add support for tables 417 | 418 | # 0.3.0 419 | 420 | * Rename --styles CLI argument to --style-map. 421 | 422 | * Rename styles argument in convert_to_html to style_map. 423 | 424 | * Allow paragraphs and runs to be matched by style name. For instance, to match 425 | a paragraph with the style name `Heading 1`: 426 | 427 | p[style-name='Heading 1'] 428 | --------------------------------------------------------------------------------