diff --git a/examples/reference/panes/Vega.ipynb b/examples/reference/panes/Vega.ipynb
index bfa88bcd2d..433ef2c07a 100644
--- a/examples/reference/panes/Vega.ipynb
+++ b/examples/reference/panes/Vega.ipynb
@@ -153,6 +153,54 @@
"vgl_pane.object = vegalite"
]
},
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "#### Exporting\n",
+ "\n",
+ "You can export the current Vega or Vega-Lite specification using the pane's `export` method.\n",
+ "\n",
+ "Supported output formats include:\n",
+ "\n",
+ "- 'png' (`Image`)\n",
+ "- 'jpeg' (`Image`)\n",
+ "- 'svg' (`SVG`)\n",
+ "- 'pdf' (`PDF`)\n",
+ "- 'html' (`HTML`)\n",
+ "- 'url' (`HTML` to Vega Editor)\n",
+ "- 'scenegraph' (`JSON`)\n",
+ "\n",
+ "Requires `vl-convert`, i.e. `pip install vl-convert-python`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "vgl_pane.export('svg')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "Additional kwargs may be passed to the [`vl-convert` functions](https://github.com/jonmmease/vl-convert/tree/main).\n",
+ "\n",
+ "To cast to a pane, use `as_pane=True`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "vgl_pane.export('png', scale=2, ppi=300, as_pane=True)"
+ ]
+ },
{
"cell_type": "markdown",
"metadata": {},
diff --git a/panel/pane/vega.py b/panel/pane/vega.py
index 7ce30cf468..19182747a1 100644
--- a/panel/pane/vega.py
+++ b/panel/pane/vega.py
@@ -1,10 +1,13 @@
from __future__ import annotations
+import datetime as dt
import re
import sys
from collections.abc import Mapping
-from typing import TYPE_CHECKING, Any, ClassVar
+from typing import (
+ TYPE_CHECKING, Any, ClassVar, Literal,
+)
import numpy as np
import param
@@ -14,23 +17,35 @@
from ..util import lazy_load
from .base import ModelPane
+from .image import PDF, SVG, Image
+from .markup import HTML, JSON
if TYPE_CHECKING:
+ import narwhals as nw
+
from bokeh.document import Document
from bokeh.model import Model
from pyviz_comms import Comm
+ VEGA_EXPORT_FORMATS = Literal['png', 'jpeg', 'svg', 'pdf', 'html', 'url', 'scenegraph']
def ds_as_cds(dataset):
"""
- Converts Vega dataset into Bokeh ColumnDataSource data
+ Converts Vega dataset into Bokeh ColumnDataSource data (Narwhals-compatible)
"""
- import pandas as pd
- if isinstance(dataset, pd.DataFrame):
- return {k: dataset[k].values for k in dataset.columns}
+ import narwhals.stable.v2 as nw
+ try:
+ df = nw.from_native(dataset)
+ except TypeError:
+ df = None
+ if isinstance(df, (nw.DataFrame, nw.LazyFrame)):
+ df = df.collect() if isinstance(df, nw.LazyFrame) else df
+ return {name: df[name].to_numpy() for name in df.columns}
+
if len(dataset) == 0:
return {}
- # create a list of unique keys from all items as some items may not include optional fields
+
+ # Create a list of unique keys from all items as some items may not include optional fields
keys = sorted({k for d in dataset for k in d.keys()})
data = {k: [] for k in keys}
for item in dataset:
@@ -39,6 +54,50 @@ def ds_as_cds(dataset):
data = {k: np.asarray(v) for k, v in data.items()}
return data
+def _is_dt_like(v):
+ return (
+ isinstance(v, (dt.date, dt.datetime, np.datetime64))
+ or (hasattr(v, "to_pydatetime") and v.__class__.__module__.startswith("pandas"))
+ )
+
+def _to_iso(v):
+ if isinstance(v, (dt.datetime, dt.date)):
+ return v.isoformat()
+ if isinstance(v, np.datetime64):
+ # choose precision to taste: "s", "ms", "us", "ns"
+ return np.datetime_as_string(v, unit="s")
+ if hasattr(v, "to_pydatetime") and v.__class__.__module__.startswith("pandas"):
+ return v.to_pydatetime().isoformat()
+ return v
+
+def _normalize_temporals_on_frame(df: nw.DataFrame) -> nw.DataFrame:
+ import narwhals.stable.v2 as nw
+ overrides = {}
+ ns = nw.get_native_namespace(df)
+ for col in df.columns:
+ dtype = df[col].dtype
+ if dtype.is_temporal():
+ overrides[col] = df[col].cast(nw.String)
+ elif dtype == nw.Object or dtype == nw.Unknown:
+ vals = df[col].to_list()
+ if any(_is_dt_like(v) for v in vals):
+ overrides[col] = nw.new_series(
+ name=col,
+ values=[_to_iso(v) for v in vals],
+ backend=ns
+ )
+ if overrides:
+ return df.with_columns(**overrides)
+ return df
+
+def ds_to_records(dataset: Any) -> list[dict[str, Any]] | None:
+ import narwhals.stable.v2 as nw
+ try:
+ df = nw.from_native(dataset)
+ except TypeError:
+ return None
+ df = _normalize_temporals_on_frame(df)
+ return df.rows(named=True)
_containers = ['hconcat', 'vconcat', 'layer']
@@ -218,6 +277,102 @@ def applies(cls, obj: Any) -> float | bool | None:
return True
return cls.is_altair(obj)
+ def export(
+ self, fmt: VEGA_EXPORT_FORMATS, as_pane: bool = False, **kwargs: dict
+ ) -> bytes | str | dict | ModelPane:
+ """
+ Exports the Vega spec to various formats.
+
+ The export method converts the Vega/Altair specification to different
+ output formats. It requires vl-convert-python to be installed.
+
+ Parameters
+ ----------
+ fmt : str
+ The format to export to. Must be one of 'png', 'jpeg', 'svg',
+ 'pdf', 'html', 'url', 'scenegraph'.
+ as_pane : bool, default False
+ If True, wraps the exported data in the appropriate Panel pane.
+ **kwargs : dict
+ Additional keyword arguments passed to the vl-convert functions.
+
+ Returns
+ -------
+ bytes | str | ModelPane
+ The exported data in the requested format, or a Panel pane if
+ as_pane=True.
+
+ Raises
+ ------
+ ImportError
+ If vl-convert-python is not installed.
+ ValueError
+ If an unsupported format is specified.
+
+ Examples
+ --------
+ >>> vega_pane = Vega(spec_dict)
+ >>> png_bytes = vega_pane.export('png')
+ >>> image_pane = vega_pane.export('png', as_pane=True)
+ """
+ try:
+ import vl_convert as vlc # type: ignore[import-untyped]
+ except ImportError:
+ raise ImportError(
+ 'vl-convert-python is required to export Vega specs. '
+ 'Please install it via `pip install vl-convert-python`.'
+ ) from None
+
+ spec = self.object if isinstance(self.object, dict) else self.object.to_dict()
+ spec = dict(spec)
+ data = spec.get('data', {})
+ if isinstance(data, list):
+ converted = []
+ for datum in data:
+ if isinstance(datum, dict) and 'values' in datum:
+ records = ds_to_records(datum['values'])
+ if records is not None:
+ datum = dict(datum, values=records)
+ converted.append(datum)
+ spec["data"] = converted
+ elif isinstance(data, dict) and 'values' in data:
+ records = ds_to_records(data['values'])
+ if records is not None:
+ spec["data"] = dict(data, values=records)
+
+ # Get dimensions from container or use spec
+ spec['width'] = self.width or spec.get("width", 800)
+ spec['height'] = self.height or spec.get("height", 600)
+
+ if 'schema/vega/' in spec.get('$schema', 'schema/vega-lite/'):
+ src = 'vega'
+ else:
+ src = 'vegalite'
+ fmt_lower = fmt.lower()
+ func_name = f"{src}_to_{fmt_lower}"
+ func = getattr(vlc, func_name, None)
+ if func is None:
+ raise ValueError(
+ f'Unsupported format {fmt!r}. Must be one of '
+ f"'png', 'jpeg', 'svg', 'pdf', 'html', or 'url'."
+ )
+ result = func(spec, **kwargs)
+ if as_pane:
+ params = {'width': self.width, 'height': self.height, 'sizing_mode': self.sizing_mode}
+ if fmt_lower == 'svg':
+ return SVG(result, **params)
+ elif fmt_lower == 'pdf':
+ return PDF(result, **params)
+ elif fmt_lower == 'html':
+ return HTML(result, **params)
+ elif fmt_lower == 'url':
+ iframe_html = f''
+ return HTML(iframe_html, **params)
+ elif fmt_lower == 'scenegraph':
+ return JSON(result)
+ return Image(result, **params)
+ return result
+
def _get_sources(self, json, sources=None):
sources = {} if sources is None else dict(sources)
datasets = json.get('datasets', {})
diff --git a/panel/tests/pane/test_vega.py b/panel/tests/pane/test_vega.py
index 0f6e098702..4dccd91665 100644
--- a/panel/tests/pane/test_vega.py
+++ b/panel/tests/pane/test_vega.py
@@ -1,4 +1,7 @@
+import sys
+
from copy import deepcopy
+from unittest.mock import patch
import pytest
@@ -19,6 +22,13 @@
from panel.models.vega import VegaPlot
from panel.pane import PaneBase, Vega
+from panel.pane.image import PDF, SVG, Image
+from panel.pane.markup import HTML
+
+try:
+ import vl_convert as vlc # type: ignore[import-untyped]
+except ImportError:
+ vlc = None
blank_schema = {'$schema': ''}
@@ -328,3 +338,213 @@ def test_altair_pane(document, comm):
def test_vega_can_instantiate_empty_with_sizing_mode(document, comm):
pane = Vega(sizing_mode="stretch_width")
pane.get_root(document, comm=comm)
+
+
+class TestVegaExport:
+ """Tests for Vega.export() method."""
+
+ @pytest.fixture
+ def vl_convert(self):
+ """Fixture to skip tests if vl_convert is not available."""
+ pytest.importorskip('vl_convert')
+
+ def test_export_requires_vl_convert(self):
+ """Test that export raises ImportError if vl_convert is not available."""
+ pane = Vega(vega_example)
+ with patch.dict(sys.modules, {'vl_convert': None}):
+ with pytest.raises(ImportError, match='vl-convert-python is required'):
+ pane.export('png')
+
+ def test_export_with_dict_spec(self, vl_convert):
+ """Test export with a dict specification."""
+ pane = Vega(vega_example)
+
+ # Test PNG export returns bytes
+ result = pane.export('png')
+ assert isinstance(result, bytes)
+ assert len(result) > 0
+
+ # Test SVG export returns string
+ result = pane.export('svg')
+ assert isinstance(result, str)
+ assert '