summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGES2
-rw-r--r--doc/config.rst2
-rw-r--r--sphinx/application.py1
-rw-r--r--sphinx/builders/__init__.py10
-rw-r--r--sphinx/builders/_epub_base.py1
-rw-r--r--sphinx/builders/epub3.py1
-rw-r--r--sphinx/builders/html.py8
-rw-r--r--sphinx/builders/latex.py30
-rw-r--r--sphinx/builders/texinfo.py31
-rw-r--r--sphinx/environment/__init__.py3
-rw-r--r--sphinx/environment/adapters/asset.py28
-rw-r--r--sphinx/environment/collectors/asset.py5
-rw-r--r--sphinx/transforms/post_transforms/__init__.py (renamed from sphinx/transforms/post_transforms.py)0
-rw-r--r--sphinx/transforms/post_transforms/images.py148
-rw-r--r--sphinx/util/__init__.py14
-rw-r--r--sphinx/util/images.py82
-rw-r--r--tests/root/images.txt2
-rw-r--r--tests/roots/test-images/index.rst6
-rw-r--r--tests/roots/test-warnings/index.rst3
-rw-r--r--tests/test_build_html.py11
-rw-r--r--tests/test_build_latex.py12
-rw-r--r--tests/test_environment.py2
-rw-r--r--tests/test_util_images.py97
23 files changed, 444 insertions, 55 deletions
diff --git a/CHANGES b/CHANGES
index d784a253e..99e5e2285 100644
--- a/CHANGES
+++ b/CHANGES
@@ -107,6 +107,8 @@ Features added
``suppress_warnings``
* #2803: Discovery of builders by entry point
* #1764, #1676: Allow setting 'rel' and 'title' attributes for stylesheets
+* #3589: Support remote images on non-HTML builders
+* #3589: Support images in Data URI on non-HTML builders
* #2961: improve :confval:`autodoc_mock_imports`. Now the config value only
requires to declare the top-level modules that should be mocked.
Thanks to Robin Jarry.
diff --git a/doc/config.rst b/doc/config.rst
index 3bc6bebb7..5e641cbfb 100644
--- a/doc/config.rst
+++ b/doc/config.rst
@@ -227,8 +227,6 @@ General configuration
* app.add_generic_role
* app.add_source_parser
* download.not_readable
- * image.data_uri
- * image.nonlocal_uri
* image.not_readable
* ref.term
* ref.ref
diff --git a/sphinx/application.py b/sphinx/application.py
index 2e3f8d7e9..26126fb9c 100644
--- a/sphinx/application.py
+++ b/sphinx/application.py
@@ -93,6 +93,7 @@ builtin_extensions = (
'sphinx.directives.patches',
'sphinx.roles',
'sphinx.transforms.post_transforms',
+ 'sphinx.transforms.post_transforms.images',
# collectors should be loaded by specific order
'sphinx.environment.collectors.dependencies',
'sphinx.environment.collectors.asset',
diff --git a/sphinx/builders/__init__.py b/sphinx/builders/__init__.py
index ad096fe7a..18f8b0c9c 100644
--- a/sphinx/builders/__init__.py
+++ b/sphinx/builders/__init__.py
@@ -61,6 +61,12 @@ class Builder(object):
# support translation
use_message_catalog = True
+ #: The list of MIME types of image formats supported by the builder.
+ #: Image files are searched in the order in which they appear here.
+ supported_image_types = [] # type: List[unicode]
+ supported_remote_images = False
+ supported_data_uri_images = False
+
def __init__(self, app):
# type: (Sphinx) -> None
self.srcdir = app.srcdir
@@ -157,10 +163,6 @@ class Builder(object):
"""Return list of paths for assets (ex. templates, CSS, etc.)."""
return []
- #: The list of MIME types of image formats supported by the builder.
- #: Image files are searched in the order in which they appear here.
- supported_image_types = [] # type: List[unicode]
-
def post_process_images(self, doctree):
# type: (nodes.Node) -> None
"""Pick the best candidate for all image URIs."""
diff --git a/sphinx/builders/_epub_base.py b/sphinx/builders/_epub_base.py
index 532384173..f7ebaa0e8 100644
--- a/sphinx/builders/_epub_base.py
+++ b/sphinx/builders/_epub_base.py
@@ -109,6 +109,7 @@ class EpubBuilder(StandaloneHTMLBuilder):
copysource = False
supported_image_types = ['image/svg+xml', 'image/png', 'image/gif',
'image/jpeg']
+ supported_remote_images = False
# don't add links
add_permalinks = False
diff --git a/sphinx/builders/epub3.py b/sphinx/builders/epub3.py
index 2ffc0ed7a..19baad344 100644
--- a/sphinx/builders/epub3.py
+++ b/sphinx/builders/epub3.py
@@ -63,6 +63,7 @@ class Epub3Builder(_epub_base.EpubBuilder):
"""
name = 'epub'
+ supported_remote_images = False
template_dir = path.join(package_dir, 'templates', 'epub3')
doctype = DOCTYPE
html_tag = HTML_TAG
diff --git a/sphinx/builders/html.py b/sphinx/builders/html.py
index a9e5e543c..c5739e896 100644
--- a/sphinx/builders/html.py
+++ b/sphinx/builders/html.py
@@ -47,6 +47,7 @@ from sphinx.highlighting import PygmentsBridge
from sphinx.util.console import bold, darkgreen # type: ignore
from sphinx.writers.html import HTMLWriter, HTMLTranslator, \
SmartyPantsHTMLTranslator
+from sphinx.environment.adapters.asset import ImageAdapter
from sphinx.environment.adapters.toctree import TocTree
from sphinx.environment.adapters.indexentries import IndexEntries
@@ -119,6 +120,8 @@ class StandaloneHTMLBuilder(Builder):
html_scaled_image_link = True
supported_image_types = ['image/svg+xml', 'image/png',
'image/gif', 'image/jpeg']
+ supported_remote_images = True
+ supported_data_uri_images = True
searchindex_filename = 'searchindex.js'
add_permalinks = True
allow_sharp_as_current_path = True
@@ -629,11 +632,12 @@ class StandaloneHTMLBuilder(Builder):
def copy_image_files(self):
# type: () -> None
- # copy image files
if self.images:
+ stringify_func = ImageAdapter(self.app.env).get_original_image_uri
ensuredir(path.join(self.outdir, self.imagedir))
for src in status_iterator(self.images, 'copying images... ', "brown",
- len(self.images), self.app.verbosity):
+ len(self.images), self.app.verbosity,
+ stringify_func=stringify_func):
dest = self.images[src]
try:
copyfile(path.join(self.srcdir, src),
diff --git a/sphinx/builders/latex.py b/sphinx/builders/latex.py
index a57105c08..ac061e08e 100644
--- a/sphinx/builders/latex.py
+++ b/sphinx/builders/latex.py
@@ -13,8 +13,6 @@ import os
import warnings
from os import path
-from six import iteritems
-
from docutils import nodes
from docutils.io import FileOutput
from docutils.utils import new_document
@@ -22,12 +20,13 @@ from docutils.frontend import OptionParser
from sphinx import package_dir, addnodes, highlighting
from sphinx.deprecation import RemovedInSphinx17Warning
-from sphinx.util import texescape, logging
from sphinx.config import string_classes, ENUM
from sphinx.errors import SphinxError
from sphinx.locale import _
from sphinx.builders import Builder
from sphinx.environment import NoUri
+from sphinx.environment.adapters.asset import ImageAdapter
+from sphinx.util import texescape, logging, status_iterator
from sphinx.util.nodes import inline_all_toctrees
from sphinx.util.fileutil import copy_asset_file
from sphinx.util.osutil import SEP, make_filename
@@ -51,6 +50,7 @@ class LaTeXBuilder(Builder):
name = 'latex'
format = 'latex'
supported_image_types = ['application/pdf', 'image/png', 'image/jpeg']
+ supported_remote_images = False
def init(self):
# type: () -> None
@@ -206,14 +206,7 @@ class LaTeXBuilder(Builder):
def finish(self):
# type: () -> None
- # copy image files
- if self.images:
- logger.info(bold('copying images...'), nonl=1)
- for src, dest in iteritems(self.images):
- logger.info(' ' + src, nonl=1)
- copy_asset_file(path.join(self.srcdir, src),
- path.join(self.outdir, dest))
- logger.info('')
+ self.copy_image_files()
# copy TeX support files from texinputs
context = {'latex_engine': self.config.latex_engine}
@@ -240,6 +233,21 @@ class LaTeXBuilder(Builder):
copy_asset_file(path.join(self.confdir, self.config.latex_logo), self.outdir)
logger.info('done')
+ def copy_image_files(self):
+ # type: () -> None
+ if self.images:
+ stringify_func = ImageAdapter(self.app.env).get_original_image_uri
+ for src in status_iterator(self.images, 'copying images... ', "brown",
+ len(self.images), self.app.verbosity,
+ stringify_func=stringify_func):
+ dest = self.images[src]
+ try:
+ copy_asset_file(path.join(self.srcdir, src),
+ path.join(self.outdir, dest))
+ except Exception as err:
+ logger.warning('cannot copy image file %r: %s',
+ path.join(self.srcdir, src), err)
+
def validate_config_values(app):
# type: (Sphinx) -> None
diff --git a/sphinx/builders/texinfo.py b/sphinx/builders/texinfo.py
index 823290255..4724aa9c3 100644
--- a/sphinx/builders/texinfo.py
+++ b/sphinx/builders/texinfo.py
@@ -11,8 +11,6 @@
from os import path
-from six import iteritems
-
from docutils import nodes
from docutils.io import FileOutput
from docutils.utils import new_document
@@ -22,9 +20,12 @@ from sphinx import addnodes
from sphinx.locale import _
from sphinx.builders import Builder
from sphinx.environment import NoUri
+from sphinx.environment.adapters.asset import ImageAdapter
from sphinx.util import logging
+from sphinx.util import status_iterator
+from sphinx.util.fileutil import copy_asset_file
from sphinx.util.nodes import inline_all_toctrees
-from sphinx.util.osutil import SEP, copyfile, make_filename
+from sphinx.util.osutil import SEP, make_filename
from sphinx.util.console import bold, darkgreen # type: ignore
from sphinx.writers.texinfo import TexinfoWriter
@@ -223,14 +224,7 @@ class TexinfoBuilder(Builder):
def finish(self):
# type: () -> None
- # copy image files
- if self.images:
- logger.info(bold('copying images...'), nonl=1)
- for src, dest in iteritems(self.images):
- logger.info(' ' + src, nonl=1)
- copyfile(path.join(self.srcdir, src),
- path.join(self.outdir, dest))
- logger.info('')
+ self.copy_image_files()
logger.info(bold('copying Texinfo support files... '), nonl=True)
# copy Makefile
@@ -243,6 +237,21 @@ class TexinfoBuilder(Builder):
logger.warning("error writing file %s: %s", fn, err)
logger.info(' done')
+ def copy_image_files(self):
+ # type: () -> None
+ if self.images:
+ stringify_func = ImageAdapter(self.app.env).get_original_image_uri
+ for src in status_iterator(self.images, 'copying images... ', "brown",
+ len(self.images), self.app.verbosity,
+ stringify_func=stringify_func):
+ dest = self.images[src]
+ try:
+ copy_asset_file(path.join(self.srcdir, src),
+ path.join(self.outdir, dest))
+ except Exception as err:
+ logger.warning('cannot copy image file %r: %s',
+ path.join(self.srcdir, src), err)
+
def setup(app):
# type: (Sphinx) -> Dict[unicode, Any]
diff --git a/sphinx/environment/__init__.py b/sphinx/environment/__init__.py
index 00d730592..303704773 100644
--- a/sphinx/environment/__init__.py
+++ b/sphinx/environment/__init__.py
@@ -257,6 +257,9 @@ class BuildEnvironment(object):
self.images = FilenameUniqDict()
self.dlfiles = FilenameUniqDict()
+ # the original URI for images
+ self.original_image_uri = {} # type: Dict[unicode, unicode]
+
# temporary data storage while reading a document
self.temp_data = {} # type: Dict[unicode, Any]
# context for cross-references (e.g. current module or class)
diff --git a/sphinx/environment/adapters/asset.py b/sphinx/environment/adapters/asset.py
new file mode 100644
index 000000000..02557a8c4
--- /dev/null
+++ b/sphinx/environment/adapters/asset.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+"""
+ sphinx.environment.adapters.asset
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Assets adapter for sphinx.environment.
+
+ :copyright: Copyright 2007-2017 by the Sphinx team, see AUTHORS.
+ :license: BSD, see LICENSE for details.
+"""
+
+if False:
+ # For type annotation
+ from sphinx.environment import BuildEnvironment # NOQA
+
+
+class ImageAdapter(object):
+ def __init__(self, env):
+ # type: (BuildEnvironment) -> None
+ self.env = env
+
+ def get_original_image_uri(self, name):
+ # type: (unicode) -> unicode
+ """Get the original image URI."""
+ while name in self.env.original_image_uri:
+ name = self.env.original_image_uri[name]
+
+ return name
diff --git a/sphinx/environment/collectors/asset.py b/sphinx/environment/collectors/asset.py
index d80698257..3a0e1fefd 100644
--- a/sphinx/environment/collectors/asset.py
+++ b/sphinx/environment/collectors/asset.py
@@ -59,14 +59,9 @@ class ImageCollector(EnvironmentCollector):
node['candidates'] = candidates
imguri = node['uri']
if imguri.startswith('data:'):
- logger.warning('image data URI found. some builders might not support',
- location=node, type='image', subtype='data_uri')
candidates['?'] = imguri
continue
elif imguri.find('://') != -1:
- logger.warning('nonlocal image URI found: %s' % imguri,
- location=node,
- type='image', subtype='nonlocal_uri')
candidates['?'] = imguri
continue
rel_imgpath, full_imgpath = app.env.relfn2path(imguri, docname)
diff --git a/sphinx/transforms/post_transforms.py b/sphinx/transforms/post_transforms/__init__.py
index 8b2a5b22c..8b2a5b22c 100644
--- a/sphinx/transforms/post_transforms.py
+++ b/sphinx/transforms/post_transforms/__init__.py
diff --git a/sphinx/transforms/post_transforms/images.py b/sphinx/transforms/post_transforms/images.py
new file mode 100644
index 000000000..9a232c1d4
--- /dev/null
+++ b/sphinx/transforms/post_transforms/images.py
@@ -0,0 +1,148 @@
+# -*- coding: utf-8 -*-
+"""
+ sphinx.transforms.post_transforms.images
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Docutils transforms used by Sphinx.
+
+ :copyright: Copyright 2007-2017 by the Sphinx team, see AUTHORS.
+ :license: BSD, see LICENSE for details.
+"""
+
+import os
+from math import ceil
+from hashlib import sha1
+
+from six import text_type
+from docutils import nodes
+
+from sphinx.transforms import SphinxTransform
+from sphinx.util import logging, requests
+from sphinx.util import epoch_to_rfc1123, rfc1123_to_epoch
+from sphinx.util.images import guess_mimetype, get_image_extension, parse_data_uri
+from sphinx.util.osutil import ensuredir
+
+if False:
+ # For type annotation
+ from typing import Any, Dict # NOQA
+ from sphinx.application import Sphinx # NOQA
+
+
+logger = logging.getLogger(__name__)
+
+
+class BaseImageConverter(SphinxTransform):
+ def apply(self):
+ # type: () -> None
+ for node in self.document.traverse(nodes.image):
+ if self.match(node):
+ self.handle(node)
+
+ def match(self, node):
+ # type: (nodes.Node) -> bool
+ return True
+
+ def handle(self, node):
+ # type: (nodes.Node) -> None
+ pass
+
+ @property
+ def imagedir(self):
+ # type: () -> unicode
+ return os.path.join(self.app.doctreedir, 'images')
+
+
+class ImageDownloader(BaseImageConverter):
+ default_priority = 100
+
+ def match(self, node):
+ # type: (nodes.Node) -> bool
+ if self.app.builder.supported_remote_images:
+ return False
+ else:
+ return '://' in node['uri']
+
+ def handle(self, node):
+ # type: (nodes.Node) -> None
+ basename = os.path.basename(node['uri'])
+ if '?' in basename:
+ basename = basename.split('?')[0]
+ dirname = node['uri'].replace('://', '/').translate({ord("?"): u"/",
+ ord("&"): u"/"})
+ ensuredir(os.path.join(self.imagedir, dirname))
+ path = os.path.join(self.imagedir, dirname, basename)
+ try:
+ headers = {}
+ if os.path.exists(path):
+ timestamp = ceil(os.stat(path).st_mtime)
+ headers['If-Modified-Since'] = epoch_to_rfc1123(timestamp)
+
+ r = requests.get(node['uri'], headers=headers)
+ if r.status_code >= 400:
+ logger.warning('Could not fetch remote image: %s [%d]' %
+ (node['uri'], r.status_code))
+ else:
+ self.app.env.original_image_uri[path] = node['uri']
+
+ if r.status_code == 200:
+ with open(path, 'wb') as f:
+ f.write(r.content)
+
+ last_modified = r.headers.get('last-modified')
+ if last_modified:
+ timestamp = rfc1123_to_epoch(last_modified)
+ os.utime(path, (timestamp, timestamp))
+
+ mimetype = guess_mimetype(path, default='*')
+ node['candidates'].pop('?')
+ node['candidates'][mimetype] = path
+ node['uri'] = path
+ self.app.env.images.add_file(self.env.docname, path)
+ except Exception as exc:
+ logger.warning('Could not fetch remote image: %s [%s]' %
+ (node['uri'], text_type(exc)))
+
+
+class DataURIExtractor(BaseImageConverter):
+ default_priority = 150
+
+ def match(self, node):
+ # type: (nodes.Node) -> bool
+ if self.app.builder.supported_data_uri_images:
+ return False
+ else:
+ return 'data:' in node['uri']
+
+ def handle(self, node):
+ # type: (nodes.Node) -> None
+ image = parse_data_uri(node['uri'])
+ ext = get_image_extension(image.mimetype)
+ if ext is None:
+ logger.warning('Unknown image format: %s...', node['uri'][:32],
+ location=node)
+ return
+
+ ensuredir(os.path.join(self.imagedir, 'embeded'))
+ digest = sha1(image.data).hexdigest()
+ path = os.path.join(self.imagedir, 'embeded', digest + ext)
+ self.app.env.original_image_uri[path] = node['uri']
+
+ with open(path, 'wb') as f:
+ f.write(image.data)
+
+ node['candidates'].pop('?')
+ node['candidates'][image.mimetype] = path
+ node['uri'] = path
+ self.app.env.images.add_file(self.env.docname, path)
+
+
+def setup(app):
+ # type: (Sphinx) -> Dict[unicode, Any]
+ app.add_post_transform(ImageDownloader)
+ app.add_post_transform(DataURIExtractor)
+
+ return {
+ 'version': 'builtin',
+ 'parallel_read_safe': True,
+ 'parallel_write_safe': True,
+ }
diff --git a/sphinx/util/__init__.py b/sphinx/util/__init__.py
index bb6c58919..8064bc68a 100644
--- a/sphinx/util/__init__.py
+++ b/sphinx/util/__init__.py
@@ -19,12 +19,15 @@ import posixpath
import traceback
import unicodedata
from os import path
+from time import mktime, strptime
from codecs import BOM_UTF8
+from datetime import datetime
from collections import deque
from six import text_type, binary_type, itervalues
from six.moves import range
from six.moves.urllib.parse import urlsplit, urlunsplit, quote_plus, parse_qsl, urlencode
+from babel.dates import format_datetime
from docutils.utils import relative_path
from sphinx.errors import PycodeError, SphinxParallelError, ExtensionError
@@ -615,3 +618,14 @@ def status_iterator(iterable, summary, color="darkgreen", length=0, verbosity=0,
yield item
if l > 0:
logger.info('')
+
+
+def epoch_to_rfc1123(epoch):
+ """Convert datetime format epoch to RFC1123."""
+ dt = datetime.fromtimestamp(epoch)
+ fmt = 'EEE, dd LLL yyyy hh:mm:ss'
+ return format_datetime(dt, fmt, locale='en') + ' GMT'
+
+
+def rfc1123_to_epoch(rfc1123):
+ return mktime(strptime(rfc1123, '%a, %d %b %Y %H:%M:%S %Z'))
diff --git a/sphinx/util/images.py b/sphinx/util/images.py
index 8de8254db..7bb904d22 100644
--- a/sphinx/util/images.py
+++ b/sphinx/util/images.py
@@ -8,10 +8,16 @@
:copyright: Copyright 2007-2017 by the Sphinx team, see AUTHORS.
:license: BSD, see LICENSE for details.
"""
+from __future__ import absolute_import
+import base64
import imghdr
import imagesize
from os import path
+from collections import OrderedDict
+
+from six import PY3, BytesIO, iteritems
+from typing import NamedTuple
try:
from PIL import Image # check for the Python Imaging Library
@@ -23,13 +29,23 @@ except ImportError:
if False:
# For type annotation
- from typing import Dict, List, Tuple # NOQA
+ from typing import Dict, IO, List, Tuple # NOQA
+
+if PY3:
+ unicode = str # special alias for static typing...
+
+mime_suffixes = OrderedDict([
+ ('.gif', 'image/gif'),
+ ('.jpg', 'image/jpeg'),
+ ('.png', 'image/png'),
+ ('.pdf', 'application/pdf'),
+ ('.svg', 'image/svg+xml'),
+ ('.svgz', 'image/svg+xml'),
+]) # type: Dict[unicode, unicode]
-mime_suffixes = {
- '.pdf': 'application/pdf',
- '.svg': 'image/svg+xml',
- '.svgz': 'image/svg+xml',
-} # type: Dict[unicode, unicode]
+DataURI = NamedTuple('DataURI', [('mimetype', unicode),
+ ('charset', unicode),
+ ('data', bytes)])
def get_image_size(filename):
@@ -52,15 +68,55 @@ def get_image_size(filename):
return None
-def guess_mimetype(filename):
- # type: (unicode) -> unicode
- _, ext = path.splitext(filename)
+def guess_mimetype_for_stream(stream, default=None):
+ # type: (IO, unicode) -> unicode
+ imgtype = imghdr.what(stream)
+ if imgtype:
+ return 'image/' + imgtype
+ else:
+ return default
+
+
+def guess_mimetype(filename='', content=None, default=None):
+ # type: (unicode, unicode, unicode) -> unicode
+ _, ext = path.splitext(filename.lower())
if ext in mime_suffixes:
return mime_suffixes[ext]
- else:
+ elif content:
+ return guess_mimetype_for_stream(BytesIO(content), default=default)
+ elif path.exists(filename):
with open(filename, 'rb') as f:
- imgtype = imghdr.what(f)
- if imgtype:
- return 'image/' + imgtype
+ return guess_mimetype_for_stream(f, default=default)
+
+ return default
+
+
+def get_image_extension(mimetype):
+ # type: (unicode) -> unicode
+ for ext, _mimetype in iteritems(mime_suffixes):
+ if mimetype == _mimetype:
+ return ext
return None
+
+
+def parse_data_uri(uri):
+ # type: (unicode) -> DataURI
+ if not uri.startswith('data:'):
+ return None
+
+ # data:[<MIME-type>][;charset=<encoding>][;base64],<data>
+ mimetype = u'text/plain'
+ charset = u'US-ASCII'
+
+ properties, data = uri[5:].split(',', 1)
+ for prop in properties.split(';'):
+ if prop == 'base64':
+ pass # skip
+ elif prop.startswith('charset='):
+ charset = prop[8:]
+ elif prop:
+ mimetype = prop
+
+ image_data = base64.b64decode(data) # type: ignore
+ return DataURI(mimetype, charset, image_data)
diff --git a/tests/root/images.txt b/tests/root/images.txt
index 3dd8e6957..55bc6f61c 100644
--- a/tests/root/images.txt
+++ b/tests/root/images.txt
@@ -16,7 +16,7 @@ Sphinx image handling
.. image:: img.*
.. a non-local image URI
-.. image:: http://www.python.org/logo.png
+.. image:: https://www.python.org/static/img/python-logo.png
.. an image with subdir and unspecified extension
.. image:: subdir/simg.*
diff --git a/tests/roots/test-images/index.rst b/tests/roots/test-images/index.rst
index 0e95b3c74..d1478fab1 100644
--- a/tests/roots/test-images/index.rst
+++ b/tests/roots/test-images/index.rst
@@ -14,3 +14,9 @@ test-image
The caption of img
.. image:: testimäge.png
+
+.. a remote image
+.. image:: https://www.python.org/static/img/python-logo.png
+
+.. non-exist remote image
+.. image:: http://example.com/NOT_EXIST.PNG
diff --git a/tests/roots/test-warnings/index.rst b/tests/roots/test-warnings/index.rst
index bef44cb4f..4110e93d0 100644
--- a/tests/roots/test-warnings/index.rst
+++ b/tests/roots/test-warnings/index.rst
@@ -15,9 +15,6 @@ test-warnings
.. an SVG image (for HTML at least)
.. image:: svgimg.*
-.. a non-local image URI
-.. image:: http://www.python.org/logo.png
-
.. should give a warning
.. literalinclude:: wrongenc.inc
:language: none
diff --git a/tests/test_build_html.py b/tests/test_build_html.py
index ad4c29dcb..9265caed4 100644
--- a/tests/test_build_html.py
+++ b/tests/test_build_html.py
@@ -32,7 +32,6 @@ WARNING: Explicit markup ends without a blank line; unexpected unindent.
%(root)s/index.rst:\\d+: WARNING: Encoding 'utf-8-sig' used for reading included \
file u'%(root)s/wrongenc.inc' seems to be wrong, try giving an :encoding: option
%(root)s/index.rst:\\d+: WARNING: image file not readable: foo.png
-%(root)s/index.rst:\\d+: WARNING: nonlocal image URI found: http://www.python.org/logo.png
%(root)s/index.rst:\\d+: WARNING: download file not readable: %(root)s/nonexisting.png
%(root)s/index.rst:\\d+: WARNING: invalid single index entry u''
%(root)s/undecodable.rst:\\d+: WARNING: undecodable source characters, replacing \
@@ -1224,3 +1223,13 @@ def test_html_raw_directive(app, status, warning):
def test_alternate_stylesheets(app, cached_etree_parse, fname, expect):
app.build()
check_xpath(cached_etree_parse(app.outdir / fname), fname, *expect)
+
+
+@pytest.mark.sphinx('html', testroot='images')
+def test_html_remote_images(app, status, warning):
+ app.builder.build_all()
+
+ result = (app.outdir / 'index.html').text(encoding='utf8')
+ assert ('<img alt="https://www.python.org/static/img/python-logo.png" '
+ 'src="https://www.python.org/static/img/python-logo.png" />' in result)
+ assert not (app.outdir / 'python-logo.png').exists()
diff --git a/tests/test_build_latex.py b/tests/test_build_latex.py
index 24ce0050a..c83b9c5f8 100644
--- a/tests/test_build_latex.py
+++ b/tests/test_build_latex.py
@@ -1042,3 +1042,15 @@ def test_latex_raw_directive(app, status, warning):
# with substitution
assert 'HTML: abc ghi' in result
assert 'LaTeX: abc def ghi' in result
+
+
+@pytest.mark.sphinx('latex', testroot='images')
+def test_latex_remote_images(app, status, warning):
+ app.builder.build_all()
+
+ result = (app.outdir / 'Python.tex').text(encoding='utf8')
+ assert '\\sphinxincludegraphics{{python-logo}.png}' in result
+ assert (app.outdir / 'python-logo.png').exists()
+ assert '\\sphinxincludegraphics{{NOT_EXIST}.PNG}' not in result
+ assert ('WARNING: Could not fetch remote image: '
+ 'http://example.com/NOT_EXIST.PNG [404]' in warning.getvalue())
diff --git a/tests/test_environment.py b/tests/test_environment.py
index 4133a28fd..22baab0dd 100644
--- a/tests/test_environment.py
+++ b/tests/test_environment.py
@@ -40,8 +40,6 @@ def test_first_update():
def test_images():
assert ('image file not readable: foo.png'
in app._warning.getvalue())
- assert ('nonlocal image URI found: http://www.python.org/logo.png'
- in app._warning.getvalue())
tree = env.get_doctree('images')
htmlbuilder = StandaloneHTMLBuilder(app)
diff --git a/tests/test_util_images.py b/tests/test_util_images.py
new file mode 100644
index 000000000..45ee66c55
--- /dev/null
+++ b/tests/test_util_images.py
@@ -0,0 +1,97 @@
+# -*- coding: utf-8 -*-
+"""
+ test_util_images
+ ~~~~~~~~~~~~~~~~
+
+ Test images util.
+
+ :copyright: Copyright 2007-2017 by the Sphinx team, see AUTHORS.
+ :license: BSD, see LICENSE for details.
+"""
+from __future__ import print_function
+
+import pytest
+
+from sphinx.util.images import (
+ get_image_size, guess_mimetype, get_image_extension, parse_data_uri
+)
+
+from util import rootdir
+
+
+GIF_FILENAME = rootdir / 'root' / 'img.gif'
+PNG_FILENAME = rootdir / 'root' / 'img.png'
+PDF_FILENAME = rootdir / 'root' / 'img.pdf'
+TXT_FILENAME = rootdir / 'root' / 'contents.txt'
+
+
+def test_get_image_size():
+ assert get_image_size(GIF_FILENAME) == (200, 181)
+ assert get_image_size(PNG_FILENAME) == (200, 181)
+ assert get_image_size(PDF_FILENAME) is None
+ assert get_image_size(TXT_FILENAME) is None
+
+
+def test_guess_mimetype():
+ # guess by filename
+ assert guess_mimetype('img.png') == 'image/png'
+ assert guess_mimetype('img.jpg') == 'image/jpeg'
+ assert guess_mimetype('img.txt') is None
+ assert guess_mimetype('img.txt', default='text/plain') == 'text/plain'
+ assert guess_mimetype('no_extension') is None
+ assert guess_mimetype('IMG.PNG') == 'image/png'
+
+ # guess by content
+ assert guess_mimetype(content=GIF_FILENAME.bytes()) == 'image/gif'
+ assert guess_mimetype(content=PNG_FILENAME.bytes()) == 'image/png'
+ assert guess_mimetype(content=PDF_FILENAME.bytes()) is None
+ assert guess_mimetype(content=TXT_FILENAME.bytes()) is None
+ assert guess_mimetype(content=TXT_FILENAME.bytes(), default='text/plain') == 'text/plain'
+
+ # the priority of params: filename > content > default
+ assert guess_mimetype('img.png',
+ content=GIF_FILENAME.bytes(),
+ default='text/plain') == 'image/png'
+ assert guess_mimetype('no_extension',
+ content=GIF_FILENAME.bytes(),
+ default='text/plain') == 'image/gif'
+ assert guess_mimetype('no_extension',
+ content=TXT_FILENAME.bytes(),
+ default='text/plain') == 'text/plain'
+
+
+def test_get_image_extension():
+ assert get_image_extension('image/png') == '.png'
+ assert get_image_extension('image/jpeg') == '.jpg'
+ assert get_image_extension('image/svg+xml') == '.svg'
+ assert get_image_extension('text/plain') is None
+
+
+def test_parse_data_uri():
+ # standard case
+ uri = ("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4"
+ "//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==")
+ image = parse_data_uri(uri)
+ assert image is not None
+ assert image.mimetype == 'image/png'
+ assert image.charset == 'US-ASCII'
+
+ # no mimetype
+ uri = ("data:charset=utf-8,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElE"
+ "QVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==")
+ image = parse_data_uri(uri)
+ assert image is not None
+ assert image.mimetype == 'text/plain'
+ assert image.charset == 'utf-8'
+
+ # non data URI
+ uri = ("image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4"
+ "//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==")
+ image = parse_data_uri(uri)
+ assert image is None
+
+ # invalid data URI (no properties)
+ with pytest.raises(ValueError):
+ uri = ("data:iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4"
+ "//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==")
+ parse_data_uri(uri)