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 ' 0 + + # Test SVG export returns string + result = pane.export('svg') + assert isinstance(result, str) + assert '' in result + + def test_export_pdf_format(self, vl_convert): + """Test PDF export format.""" + pane = Vega(vega_example) + result = pane.export('pdf') + assert isinstance(result, bytes) + # PDF files start with %PDF + assert result.startswith(b'%PDF') + + def test_export_html_format(self, vl_convert): + """Test HTML export format.""" + pane = Vega(vega_example) + result = pane.export('html') + assert isinstance(result, str) + assert ' spec values > defaults (800x600).""" + # Test 1: Default dimensions when nothing specified + spec_no_dims = { + '$schema': 'https://vega.github.io/schema/vega-lite/v5.0.0.json', + 'mark': 'bar', + 'data': {'values': [{'x': 'A', 'y': 5}]}, + 'encoding': {'x': {'field': 'x', 'type': 'ordinal'}, + 'y': {'field': 'y', 'type': 'quantitative'}} + } + pane = Vega(spec_no_dims) + result = pane.export('svg') + assert '800' in result and '600' in result + + # Test 2: Spec dimensions used when pane has none + spec = dict(vega_example) + spec['width'] = 500 + spec['height'] = 400 + pane = Vega(spec, width=None, height=None) + result = pane.export('svg') + assert '500' in result and '400' in result + + # Test 3: Pane width overrides spec width, spec height preserved + spec['width'] = 300 + spec['height'] = 250 + pane = Vega(spec, width=700) + result = pane.export('svg') + assert '700' in result and '250' in result + + # Test 4: Pane height overrides spec height, spec width preserved + pane = Vega(spec, height=600) + result = pane.export('svg') + assert '300' in result and '600' in result + + # Test 5: Both pane dimensions override spec + pane = Vega(spec, width=1000, height=800) + result = pane.export('svg') + assert '1000' in result and '800' in result + + @altair_available + def test_export_altair_dimensions(self, vl_convert): + """Test dimension handling with Altair charts.""" + # Altair chart dimensions in spec + chart = altair_example() + chart = chart.properties(width=600, height=400) + pane = Vega(chart) + result = pane.export('svg') + assert '600' in result and '400' in result + + # Pane dimensions override Altair dimensions + chart = chart.properties(width=300, height=200) + pane = Vega(chart, width=1000, height=750) + result = pane.export('svg') + assert '1000' in result and '750' in result + + def test_export_kwargs_passed_to_vl_convert(self, vl_convert): + """Test that additional kwargs are passed to vl_convert functions.""" + pane = Vega(vega_example) + with patch.object(vlc, 'vegalite_to_png', return_value=b'fake_png') as mock_convert: + result = pane.export('png', scale=2.0) + assert result == b'fake_png' + assert mock_convert.call_args[1]['scale'] == 2.0 + + @pytest.mark.parametrize('fmt,expected_pane', [ + ('png', Image), + ('jpeg', Image), + ('svg', SVG), + ('pdf', PDF), + ('html', HTML), + ]) + def test_export_as_pane(self, vl_convert, fmt, expected_pane): + """Test export with as_pane=True returns correct pane type.""" + pane = Vega(vega_example) + result = pane.export(fmt, as_pane=True) + assert isinstance(result, expected_pane) + + def test_export_as_pane_url(self, vl_convert): + """Test URL export with as_pane=True returns HTML pane with iframe.""" + pane = Vega(vega_example) + result = pane.export('url', as_pane=True) + assert isinstance(result, HTML) + # Check that the object contains an iframe tag + assert '=2', 'requests', 'bleach', 'typing_extensions',