From 0f26c0dd860fda7b4eda7e2eaace82e840423953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Freitag?= Date: Tue, 19 Nov 2019 16:02:58 +0100 Subject: [PATCH] Basic support for inline images in documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broken image from https://freesvg.org/broken-icon, licensed under CC0 (Creative commons, “No Rights Reserved”). Reviewed-by: Jon Dufresne Reviewed-by: Roman Danilov --- MANIFEST.in | 1 + html2docx/html2docx.py | 11 +++- html2docx/image-broken.png | Bin 0 -> 35943 bytes html2docx/image.py | 51 ++++++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 103 +++++++++++++++++++++++++++++++++++++ tests/data/1x1.png | Bin 0 -> 106 bytes tests/data/img.html | 1 + tests/data/img.json | 17 ++++++ tests/images/1x1.png | Bin 0 -> 106 bytes tests/test_html2docx.py | 12 +++-- tests/test_load_image.py | 60 +++++++++++++++++++++ tests/utils.py | 4 ++ 13 files changed, 256 insertions(+), 4 deletions(-) create mode 100644 html2docx/image-broken.png create mode 100644 html2docx/image.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/data/1x1.png create mode 100644 tests/data/img.html create mode 100644 tests/data/img.json create mode 100644 tests/images/1x1.png create mode 100644 tests/test_load_image.py create mode 100644 tests/utils.py diff --git a/MANIFEST.in b/MANIFEST.in index 786e967..9d4319a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,6 @@ include LICENSE include README.md include tox.ini +include html2docx/image-broken.png include html2docx/py.typed recursive-include tests *.html *.json *.py diff --git a/html2docx/html2docx.py b/html2docx/html2docx.py index 96ee4b7..cc0c47d 100644 --- a/html2docx/html2docx.py +++ b/html2docx/html2docx.py @@ -10,6 +10,8 @@ from tinycss2 import parse_declaration_list from tinycss2.ast import DimensionToken, IdentToken +from .image import load_image + WHITESPACE_RE = re.compile(r"\s+") ALIGNMENTS = { @@ -126,9 +128,14 @@ def add_list_style(self, name: str) -> None: suffix = f" {level}" if level > 1 else "" self.list_style.append(f"{name}{suffix}") + def add_picture(self, attrs: List[Tuple[str, Optional[str]]]) -> None: + src = get_attr(attrs, "src") + image_buffer = load_image(src) + self.doc.add_picture(image_buffer) + def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> None: if tag == "a": - self.href = str(next((val for name, val in attrs if name == "href"), "")) + self.href = get_attr(attrs, "href") self.init_run([]) elif tag in ["b", "strong"]: self.init_run(["bold"]) @@ -140,6 +147,8 @@ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> N elif tag in ["h1", "h2", "h3", "h4", "h5", "h6"]: level = int(tag[-1]) self.p = self.doc.add_heading(level=level) + elif tag == "img": + self.add_picture(attrs) elif tag == "ol": self.add_list_style("List Number") elif tag == "p": diff --git a/html2docx/image-broken.png b/html2docx/image-broken.png new file mode 100644 index 0000000000000000000000000000000000000000..b0c7e1284e2927280e5de7b5f80be722a5fc8bf7 GIT binary patch literal 35943 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 H{m%aZJ$9SX literal 0 HcmV?d00001 diff --git a/html2docx/image.py b/html2docx/image.py new file mode 100644 index 0000000..f760686 --- /dev/null +++ b/html2docx/image.py @@ -0,0 +1,51 @@ +import http +import io +import pathlib +import time +import urllib.error +import urllib.request + +from docx.image.exceptions import UnrecognizedImageError +from docx.image.image import Image + +MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10 MiB + + +def load_image(src: str) -> io.BytesIO: + image_buffer = None + retry = 3 + while retry and not image_buffer: + try: + with urllib.request.urlopen(src) as response: + size = response.getheader("Content-Length") + if size and int(size) > MAX_IMAGE_SIZE: + break + # Read up to MAX_IMAGE_SIZE when response does not contain + # the Content-Length header. The extra byte avoids an extra read to + # check whether the EOF was reached. + data = response.read(MAX_IMAGE_SIZE + 1) + except (ValueError, http.client.HTTPException, urllib.error.HTTPError): + # ValueError: Invalid URL or non-integer Content-Length. + # HTTPException: Server does not speak HTTP properly. + # HTTPError: Server could not perform request. + retry = 0 + except urllib.error.URLError: + # URLError: Transient network error, e.g. DNS request failed. + retry -= 1 + if retry: + time.sleep(1) + else: + if len(data) <= MAX_IMAGE_SIZE: + image_buffer = io.BytesIO(data) + + if image_buffer: + try: + Image.from_blob(image_buffer.getbuffer()) + except UnrecognizedImageError: + image_buffer = None + + if not image_buffer: + broken_img_path = pathlib.Path(__file__).parent / "image-broken.png" + image_buffer = io.BytesIO(broken_img_path.read_bytes()) + + return image_buffer diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8b414aa --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,103 @@ +import http.server +import os +import sys +import threading + +import pytest + +from .utils import TEST_DIR + + +class CountingHTTPServer(http.server.HTTPServer): + request_count = 0 + + def finish_request(self, *args, **kwargs): + self.request_count += 1 + return super().finish_request(*args, **kwargs) + + +class HttpServerThread(threading.Thread): + def __init__(self, handler): + super().__init__() + self.is_ready = threading.Event() + self.handler = handler + self.error = None + + def run(self): + try: + self.httpd = CountingHTTPServer(("localhost", 0), self.handler) + port = self.httpd.server_address[1] + self.base_url = f"http://localhost:{port}/" + self.is_ready.set() + self.httpd.serve_forever(poll_interval=0.01) + except Exception as e: + self.error = e + self.is_ready.set() + + def terminate(self): + if hasattr(self, "httpd"): + self.httpd.shutdown() + self.httpd.server_close() + self.join() + + +class ImageHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, directory=None, **kwargs): + if sys.version_info >= (3, 9): + kwargs["directory"] = TEST_DIR / "images" + elif sys.version_info >= (3, 7): + kwargs["directory"] = os.fspath(TEST_DIR / "images") + super().__init__(*args, **kwargs) + + def translate_path(self, path): + if sys.version_info < (3, 7): + cwd = os.getcwd() + try: + os.chdir(TEST_DIR / "images") + return super().translate_path(path) + finally: + os.chdir(cwd) + return super().translate_path(path) + + +def http_server_thread(handler): + server_thread = HttpServerThread(handler) + server_thread.daemon = True + server_thread.start() + server_thread.is_ready.wait() + yield server_thread + try: + if server_thread.error: + raise server_thread.error + finally: + server_thread.terminate() + + +@pytest.fixture(scope="function") +def image_server(): + """ + Start a HTTP server serving test images. + """ + yield from http_server_thread(ImageHandler) + + +class BadContentHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + self.close_connection = True + + +@pytest.fixture(scope="function") +def bad_server(): + yield from http_server_thread(BadContentHandler) + + +class BadContentLengthHandler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + self.send_response(http.HTTPStatus.OK) + self.send_header("Content-Length", "invalid") + self.end_headers() + + +@pytest.fixture(scope="function") +def bad_content_length_server(): + yield from http_server_thread(BadContentLengthHandler) diff --git a/tests/data/1x1.png b/tests/data/1x1.png new file mode 100644 index 0000000000000000000000000000000000000000..90a7589d349e766b5e5f7bc613ef9279968b6fe0 GIT binary patch literal 106 zcmeAS@N?(olHy`uVBq!ia0vp^j3CU&3?x-=hn)gaYymzYu4m4inKo^j$gMZ0fg+p* x9+AZi417mGm~pB$pEOXA%hSa%gkxrM0+7wb!1$#wWf_pg;OXk;vd$@?2>{d<85sZo literal 0 HcmV?d00001 diff --git a/tests/data/img.html b/tests/data/img.html new file mode 100644 index 0000000..e8d1f45 --- /dev/null +++ b/tests/data/img.html @@ -0,0 +1 @@ + diff --git a/tests/data/img.json b/tests/data/img.json new file mode 100644 index 0000000..2f47314 --- /dev/null +++ b/tests/data/img.json @@ -0,0 +1,17 @@ +[ + { + "text": "", + "runs": [ + { + "text": "", + "shapes": [ + { + "type": 3, + "width": 9525, + "height": 9525 + } + ] + } + ] + } +] diff --git a/tests/images/1x1.png b/tests/images/1x1.png new file mode 100644 index 0000000000000000000000000000000000000000..90a7589d349e766b5e5f7bc613ef9279968b6fe0 GIT binary patch literal 106 zcmeAS@N?(olHy`uVBq!ia0vp^j3CU&3?x-=hn)gaYymzYu4m4inKo^j$gMZ0fg+p* x9+AZi417mGm~pB$pEOXA%hSa%gkxrM0+7wb!1$#wWf_pg;OXk;vd$@?2>{d<85sZo literal 0 HcmV?d00001 diff --git a/tests/test_html2docx.py b/tests/test_html2docx.py index 27c475e..3cef917 100644 --- a/tests/test_html2docx.py +++ b/tests/test_html2docx.py @@ -1,5 +1,4 @@ import json -import pathlib import docx import pytest @@ -7,8 +6,7 @@ from html2docx import html2docx -TEST_DIR = pathlib.Path(__file__).parent.resolve(strict=True) -PROJECT_DIR = TEST_DIR.parent +from .utils import PROJECT_DIR, TEST_DIR FONT_ATTRS = ["bold", "italic", "strike", "subscript", "superscript", "underline"] @@ -51,6 +49,7 @@ def test_html2docx(html_path, spec_path): assert len(p.runs) == len(runs_spec) for run, run_spec in zip(p.runs, runs_spec): assert run.text == run_spec.pop("text") + shapes_spec = run_spec.pop("shapes", None) unknown = set(run_spec).difference(FONT_ATTRS) assert not unknown, "Unknown attributes in {}: {}".format( spec_rel_path, ", ".join(unknown) @@ -58,3 +57,10 @@ def test_html2docx(html_path, spec_path): for attr in FONT_ATTRS: msg = f"Wrong {attr} for text '{run.text}' in {html_rel_path}" assert getattr(run.font, attr) is run_spec.get(attr), msg + if shapes_spec: + shapes = run.part.inline_shapes + assert len(shapes) == len(shapes_spec) + for shape, shape_spec in zip(shapes, shapes_spec): + assert shape.type == shape_spec["type"] + assert shape.width == shape_spec["width"] + assert shape.height == shape_spec["height"] diff --git a/tests/test_load_image.py b/tests/test_load_image.py new file mode 100644 index 0000000..7212cab --- /dev/null +++ b/tests/test_load_image.py @@ -0,0 +1,60 @@ +import urllib.error +import urllib.request +from unittest import mock + +from html2docx.image import load_image + +from .utils import PROJECT_DIR, TEST_DIR + +broken_image = PROJECT_DIR / "html2docx" / "image-broken.png" +broken_image_bytes = broken_image.read_bytes() + + +def test_basic(image_server): + image_data = load_image(image_server.base_url + "1x1.png") + expected = TEST_DIR / "data" / "1x1.png" + assert image_data.getbuffer() == expected.read_bytes() + + +def test_non_image(image_server): + image_data = load_image(image_server.base_url) + assert image_data.getbuffer() == broken_image_bytes + + +def test_bad_url(): + image_data = load_image("bad") + assert image_data.getbuffer() == broken_image_bytes + + +def test_transient_network_error_retries(): + url = "https://transient.network.issue.com/image.png" + with mock.patch( + "html2docx.image.urllib.request.urlopen", + autospec=True, + side_effect=urllib.error.URLError( + reason="[Errno -2] Name or service not known" + ), + ) as url_mock: + with mock.patch("html2docx.image.time.sleep", autospec=True) as time_mock: + image_data = load_image(url) + assert time_mock.mock_calls == [mock.call(1)] * 2 + assert url_mock.call_args_list == [mock.call(url)] * 3 + assert image_data.getbuffer() == broken_image_bytes + + +def test_404(image_server): + image_data = load_image(image_server.base_url + "nonexistent") + assert image_data.getbuffer() == broken_image_bytes + assert image_server.httpd.request_count == 1 + + +def test_bad_server(bad_server): + image_data = load_image(bad_server.base_url) + assert image_data.getbuffer() == broken_image_bytes + assert bad_server.httpd.request_count == 1 + + +def test_bad_content_length(bad_content_length_server): + image_data = load_image(bad_content_length_server.base_url) + assert image_data.getbuffer() == broken_image_bytes + assert bad_content_length_server.httpd.request_count == 1 diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..916f68e --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,4 @@ +import pathlib + +TEST_DIR = pathlib.Path(__file__).parent.resolve(strict=True) +PROJECT_DIR = TEST_DIR.parent