Repository: altair-viz/altair
Pull request #3244 (2023-10-30 11:51:49)

Support saving to PDF with VlConvert 1.0
VlConvert 1.0 was just released and it includes support for saving charts to PDF. This PR bumps the minimum version of vl-convert-python to 1.0.0 and adds support for saving to PDF with the `vl-convert` engine.

This brings vl-convert to feature parity with `altair_saver`, so I added a deprecation warning when the altair_saver engine is used.  I'm thinking we could drop altair_saver support in Altair 5.3.0.


Patch:
diff --git a/altair/utils/_importers.py b/altair/utils/_importers.py
index 6ee41351..388d379f 100644
--- a/altair/utils/_importers.py
+++ b/altair/utils/_importers.py
@@ -8,7 +8,7 @@ def import_vegafusion() -> ModuleType:
     try:
         version = importlib_version("vegafusion")
         if Version(version) < Version(min_version):
-            raise ImportError(
+            raise RuntimeError(
                 f"The vegafusion package must be version {min_version} or greater. "
                 f"Found version {version}"
             )
@@ -29,11 +29,11 @@ def import_vegafusion() -> ModuleType:
 
 
 def import_vl_convert() -> ModuleType:
-    min_version = "0.14.0"
+    min_version = "1.0.0"
     try:
         version = importlib_version("vl-convert-python")
         if Version(version) < Version(min_version):
-            raise ImportError(
+            raise RuntimeError(
                 f"The vl-convert-python package must be version {min_version} or greater. "
                 f"Found version {version}"
             )
@@ -58,7 +58,7 @@ def import_pyarrow_interchange() -> ModuleType:
         version = importlib_version("pyarrow")
 
         if Version(version) < Version(min_version):
-            raise ImportError(
+            raise RuntimeError(
                 f"The pyarrow package must be version {min_version} or greater. "
                 f"Found version {version}"
             )
diff --git a/altair/utils/_vegafusion_data.py b/altair/utils/_vegafusion_data.py
index 83bb8d06..920082b5 100644
--- a/altair/utils/_vegafusion_data.py
+++ b/altair/utils/_vegafusion_data.py
@@ -182,7 +182,7 @@ def compile_with_vegafusion(vegalite_spec: dict) -> dict:
 
 
 def using_vegafusion() -> bool:
-    """Check whether the vegafusion data transfomer is enabled"""
+    """Check whether the vegafusion data transformer is enabled"""
     # Local import to avoid circular ImportError
     from altair import data_transformers
 
diff --git a/altair/utils/mimebundle.py b/altair/utils/mimebundle.py
index a0964899..fa36ed4b 100644
--- a/altair/utils/mimebundle.py
+++ b/altair/utils/mimebundle.py
@@ -1,6 +1,8 @@
+from .deprecation import AltairDeprecationWarning
 from .html import spec_to_html
 from ._importers import import_vl_convert
 import struct
+import warnings
 
 
 def spec_to_mimebundle(
@@ -145,11 +147,31 @@ def _spec_to_mimebundle_with_engine(spec, format, mode, **kwargs):
             return {"image/png": png}, {
                 "image/png": {"width": w / factor, "height": h / factor}
             }
+        elif format == "pdf":
+            scale = kwargs.get("scale_factor", 1)
+            if mode == "vega":
+                pdf = vlc.vega_to_pdf(
+                    spec,
+                    scale=scale,
+                )
+            else:
+                pdf = vlc.vegalite_to_pdf(
+                    spec,
+                    vl_version=vl_version,
+                    scale=scale,
+                )
+            return {"application/pdf": pdf}
         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":
+        warnings.warn(
+            "The altair_saver export engine is deprecated and will be removed in a future version.\n"
+            "Please migrate to the vl-convert engine",
+            AltairDeprecationWarning,
+            stacklevel=1,
+        )
         import altair_saver
 
         return altair_saver.render(spec, format, mode=mode, **kwargs)
@@ -192,18 +214,13 @@ def _validate_normalize_engine(engine, format):
             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":
+        if vlc is not None:
             normalized_engine = "vlconvert"
         elif altair_saver is not None:
             normalized_engine = "altairsaver"
diff --git a/doc/user_guide/saving_charts.rst b/doc/user_guide/saving_charts.rst
index c9b32f24..e265c85d 100644
--- a/doc/user_guide/saving_charts.rst
+++ b/doc/user_guide/saving_charts.rst
@@ -172,9 +172,8 @@ or::
     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>`_.
+See the vl-convert documentation for information and for known
+`limitations <https://github.com/vega/vl-convert#limitations>`_.
 
 altair_saver
 ^^^^^^^^^^^^
diff --git a/pyproject.toml b/pyproject.toml
index 527d03f0..af321ba5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -64,7 +64,7 @@ dev = [
     "pytest-cov",
     "m2r",
     "vega_datasets",
-    "vl-convert-python>=0.14.0",
+    "vl-convert-python>=1.0.0",
     "mypy",
     "pandas-stubs",
     "types-jsonschema",


Test patch:
diff --git a/tests/vegalite/v5/test_api.py b/tests/vegalite/v5/test_api.py
index 60dedc33..c61d466d 100644
--- a/tests/vegalite/v5/test_api.py
+++ b/tests/vegalite/v5/test_api.py
@@ -322,23 +322,15 @@ def test_save(format, engine, basic_chart):
                 return
 
         elif engine == "vl-convert":
-            if vlc is None and format != "bogus":
-                with pytest.raises(ValueError) as err:
-                    basic_chart.save(out, format=format, engine=engine)
-                assert "vl-convert-python" in str(err.value)
-                return
-            elif format == "pdf":
+            if format == "bogus":
                 with pytest.raises(ValueError) as err:
                     basic_chart.save(out, format=format, engine=engine)
-                assert (
-                    f"The 'vl-convert' conversion engine does not support the '{format}' format"
-                    in str(err.value)
-                )
+                assert f"Unsupported format: '{format}'" in str(err.value)
                 return
-            elif format not in ("png", "svg"):
+            elif vlc is None:
                 with pytest.raises(ValueError) as err:
                     basic_chart.save(out, format=format, engine=engine)
-                assert f"Unsupported format: '{format}'" in str(err.value)
+                assert "vl-convert-python" in str(err.value)
                 return
 
     basic_chart.save(out, format=format, engine=engine)
@@ -353,6 +345,8 @@ def test_save(format, engine, basic_chart):
         assert content.startswith("<svg")
     elif format == "png":
         assert content.startswith(b"\x89PNG")
+    elif format == "pdf":
+        assert content.startswith(b"%PDF-")
 
     fid, filename = tempfile.mkstemp(suffix="." + format)
     os.close(fid)