Merge pull request #3589 from tk0miya/support_remote_images

Support remote images
This commit is contained in:
Takeshi KOMIYA 2017-04-22 10:08:06 +09:00 committed by GitHub
commit 45887c7d62
23 changed files with 444 additions and 55 deletions

View File

@ -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.

View File

@ -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

View File

@ -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',

View File

@ -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."""

View File

@ -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

View File

@ -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

View File

@ -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),

View File

@ -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

View File

@ -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]

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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,
}

View File

@ -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'))

View File

@ -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
mime_suffixes = {
'.pdf': 'application/pdf',
'.svg': 'image/svg+xml',
'.svgz': 'image/svg+xml',
} # type: Dict[unicode, unicode]
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]
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)

View File

@ -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.*

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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())

View File

@ -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)

97
tests/test_util_images.py Normal file
View File

@ -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)