Skip to content

Commit

Permalink
Merge pull request #2701 from jonmmease/vl_convert_export
Browse files Browse the repository at this point in the history
Integrate vl-convert for saving to svg and png
  • Loading branch information
mattijn authored Nov 1, 2022
2 parents c83aca6 + a163f8f commit acd98d9
Show file tree
Hide file tree
Showing 5 changed files with 196 additions and 35 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
python -m pip install --upgrade pip
pip install .[dev]
pip install "selenium<4.3.0"
pip install altair_saver
pip install altair_saver vl-convert-python
pip install git+https://github.com/altair-viz/altair_viewer.git
- name: Test with pytest
run: |
Expand Down
42 changes: 25 additions & 17 deletions altair/examples/tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,47 @@
from altair.utils.execeval import eval_block
from altair import examples

try:
import altair_saver # noqa: F401
except ImportError:
altair_saver = None

@pytest.fixture
def require_altair_saver_png():
try:
import altair_saver # noqa: F401
except ImportError:
pytest.skip("altair_saver not importable; cannot run saver tests")
if "png" not in altair_saver.available_formats('vega-lite'):
pytest.skip("altair_saver not configured to save to png")
try:
import vl_convert as vlc # noqa: F401
except ImportError:
vlc = None


def iter_example_filenames():
for importer, modname, ispkg in pkgutil.iter_modules(examples.__path__):
if ispkg or modname.startswith('_'):
if ispkg or modname.startswith("_"):
continue
yield modname + '.py'
yield modname + ".py"


@pytest.mark.parametrize('filename', iter_example_filenames())
@pytest.mark.parametrize("filename", iter_example_filenames())
def test_examples(filename: str):
source = pkgutil.get_data(examples.__name__, filename)
chart = eval_block(source)

if chart is None:
raise ValueError("Example file should define chart in its final "
"statement.")
raise ValueError("Example file should define chart in its final " "statement.")
chart.to_dict()


@pytest.mark.parametrize('filename', iter_example_filenames())
def test_render_examples_to_png(require_altair_saver_png, filename):
@pytest.mark.parametrize("engine", ["vl-convert", "altair_saver"])
@pytest.mark.parametrize("filename", iter_example_filenames())
def test_render_examples_to_png(engine, filename):
if engine == "vl-convert" and vlc is None:
pytest.skip("vl_convert not importable; cannot run mimebundle tests")
elif engine == "altair_saver":
if altair_saver is None:
pytest.skip("altair_saver not importable; cannot run png tests")
if "png" not in altair_saver.available_formats("vega-lite"):
pytest.skip("altair_saver not configured to save to png")

source = pkgutil.get_data(examples.__name__, filename)
chart = eval_block(source)
out = io.BytesIO()
chart.save(out, format="png")
assert out.getvalue().startswith(b'\x89PNG')
chart.save(out, format="png", engine=engine)
assert out.getvalue().startswith(b"\x89PNG")
133 changes: 125 additions & 8 deletions altair/utils/mimebundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,7 @@ def spec_to_mimebundle(
raise ValueError("Must specify vega_version")
return {"application/vnd.vega.v{}+json".format(vega_version[0]): spec}
if format in ["png", "svg", "pdf", "vega"]:
try:
import altair_saver
except ImportError:
raise ValueError(
"Saving charts in {fmt!r} format requires the altair_saver package: "
"see http://github.com/altair-viz/altair_saver/".format(fmt=format)
)
return altair_saver.render(spec, format, mode=mode, **kwargs)
return _spec_to_mimebundle_with_engine(spec, format, mode, **kwargs)
if format == "html":
html = spec_to_html(
spec,
Expand All @@ -81,3 +74,127 @@ def spec_to_mimebundle(
"format must be one of "
"['html', 'json', 'png', 'svg', 'pdf', 'vega', 'vega-lite']"
)


def _spec_to_mimebundle_with_engine(spec, format, mode, **kwargs):
"""Helper for Vega-Lite to mimebundle conversions that require an engine
Parameters
----------
spec : dict
a dictionary representing a vega-lite plot spec
format : string {'png', 'svg', 'pdf', 'vega'}
the format of the mimebundle to be returned
mode : string {'vega', 'vega-lite'}
The rendering mode.
engine: string {'vl-convert', 'altair_saver'}
the conversion engine to use
**kwargs :
Additional arguments will be passed to the conversion function
"""
# Normalize the engine string (if any) by lower casing
# and removing underscores and hyphens
engine = kwargs.pop("engine", None)
normalized_engine = _validate_normalize_engine(engine, format)

if normalized_engine == "vlconvert":
import vl_convert as vlc
from ..vegalite import SCHEMA_VERSION

# Compute VlConvert's vl_version string (of the form 'v5_2')
# from SCHEMA_VERSION (of the form 'v5.2.0')
vl_version = "_".join(SCHEMA_VERSION.split(".")[:2])

if format == "vega":
vg = vlc.vegalite_to_vega(spec, vl_version=vl_version)
return {"application/vnd.vega.v5+json": vg}
elif format == "svg":
svg = vlc.vegalite_to_svg(spec, vl_version=vl_version)
return {"image/svg+xml": svg}
elif format == "png":
png = vlc.vegalite_to_png(
spec,
vl_version=vl_version,
scale=kwargs.get("scale_factor", 1.0),
)
return {"image/png": png}
else:
# This should be validated above
# but raise exception for the sake of future development
raise ValueError("Unexpected format {fmt!r}".format(fmt=format))
elif normalized_engine == "altairsaver":
import altair_saver

return altair_saver.render(spec, format, mode=mode, **kwargs)
else:
# This should be validated above
# but raise exception for the sake of future development
raise ValueError(
"Unexpected normalized_engine {eng!r}".format(eng=normalized_engine)
)


def _validate_normalize_engine(engine, format):
"""Helper to validate and normalize the user-provided engine
engine : {None, 'vl-convert', 'altair_saver'}
the user-provided engine string
format : string {'png', 'svg', 'pdf', 'vega'}
the format of the mimebundle to be returned
"""
# Try to import vl_convert
try:
import vl_convert as vlc
except ImportError:
vlc = None

# Try to import altair_saver
try:
import altair_saver
except ImportError:
altair_saver = None

# Normalize engine string by lower casing and removing underscores and hyphens
normalized_engine = (
None if engine is None else engine.lower().replace("-", "").replace("_", "")
)

# Validate or infer default value of normalized_engine
if normalized_engine == "vlconvert":
if vlc is None:
raise ValueError(
"The 'vl-convert' conversion engine requires the vl-convert-python package"
)
if format == "pdf":
raise ValueError(
"The 'vl-convert' conversion engine does not support the {fmt!r} format.\n"
"Use the 'altair_saver' engine instead".format(fmt=format)
)
elif normalized_engine == "altairsaver":
if altair_saver is None:
raise ValueError(
"The 'altair_saver' conversion engine requires the altair_saver package"
)
elif normalized_engine is None:
if vlc is not None and format != "pdf":
normalized_engine = "vlconvert"
elif altair_saver is not None:
normalized_engine = "altairsaver"
else:
if format == "pdf":
raise ValueError(
"Saving charts in {fmt!r} format requires the altair_saver package: "
"see http://github.com/altair-viz/altair_saver/".format(fmt=format)
)
else:
raise ValueError(
"Saving charts in {fmt!r} format requires the vl-convert-python or altair_saver package: "
"see http://github.com/altair-viz/altair_saver/".format(fmt=format)
)
else:
raise ValueError(
"Invalid conversion engine {engine!r}. Expected one of {valid!r}".format(
engine=engine, valid=("vl-convert", "altair_saver")
)
)
return normalized_engine
23 changes: 16 additions & 7 deletions altair/utils/tests/test_mimebundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
import altair as alt
from ..mimebundle import spec_to_mimebundle

try:
import altair_saver # noqa: F401
except ImportError:
altair_saver = None

@pytest.fixture
def require_altair_saver():
try:
import altair_saver # noqa: F401
except ImportError:
pytest.skip("altair_saver not importable; cannot run saver tests")
try:
import vl_convert as vlc # noqa: F401
except ImportError:
vlc = None


@pytest.fixture
Expand Down Expand Up @@ -165,7 +167,13 @@ def vega_spec():
}


def test_vegalite_to_vega_mimebundle(require_altair_saver, vegalite_spec, vega_spec):
@pytest.mark.parametrize("engine", ["vl-convert", "altair_saver", None])
def test_vegalite_to_vega_mimebundle(engine, vegalite_spec, vega_spec):
if engine == "vl-convert" and vlc is None:
pytest.skip("vl_convert not importable; cannot run mimebundle tests")
elif engine == "altair_saver" and altair_saver is None:
pytest.skip("altair_saver not importable; cannot run mimebundle tests")

# temporary fix for https://github.com/vega/vega-lite/issues/7776
def delete_none(axes):
for axis in axes:
Expand All @@ -181,6 +189,7 @@ def delete_none(axes):
vega_version=alt.VEGA_VERSION,
vegalite_version=alt.VEGALITE_VERSION,
vegaembed_version=alt.VEGAEMBED_VERSION,
engine=engine,
)

bundle["application/vnd.vega.v5+json"]["axes"] = delete_none(
Expand Down
31 changes: 29 additions & 2 deletions doc/user_guide/saving_charts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,23 @@ To save an Altair chart object as a PNG, SVG, or PDF image, you can use
However, saving these images requires some additional extensions to run the
javascript code necessary to interpret the Vega-Lite specification and output
it in the form of an image.
it in the form of an image. There are two packages that can be used to enable
image export: vl-convert-python_ or altair_saver_.

Altair can do this via the altair_saver_ package, which can be installed with::
vl-convert
^^^^^^^^^^
The vl-convert_ package can be installed with::

$ pip install vl-convert-python

Unlike altair_saver_, vl-convert_ does not require any external dependencies.
However, it only supports saving charts to PNG and SVG formats. To save directly to
PDF, altair_saver_ is still required. See the vl-convert documentation for information
on other `limitations <https://github.com/vega/vl-convert#limitations>`_.

altair_saver
^^^^^^^^^^^^
The altair_saver_ package can be installed with::

$ conda install altair_saver

Expand All @@ -171,6 +185,18 @@ or::
See the altair_saver_ documentation for information about additional installation
requirements.

Engine Argument
^^^^^^^^^^^^^^^
If both vl-convert and altair_saver are installed, vl-convert will take precedence.
The engine argument to :meth:`Chart.save` can be used to override this default
behavior. For example, to use altair_saver for PNG export when vl-convert is also
installed you can use::

.. code-block:: python
chart.save('chart.png', engine="altair_saver")
Figure Size/Resolution
^^^^^^^^^^^^^^^^^^^^^^
When using ``chart.save()`` above, the resolution of the resulting PNG is
Expand All @@ -183,5 +209,6 @@ This can be done with the ``scale_factor`` argument, which defaults to 1.0::
chart.save('chart.png', scale_factor=2.0)


.. _vl-convert: https://github.com/vega/vl-convert
.. _altair_saver: http://github.com/altair-viz/altair_saver/
.. _vegaEmbed: https://github.com/vega/vega-embed

0 comments on commit acd98d9

Please sign in to comment.