11from  __future__ import  annotations 
22
3+ import  datetime  as  dt 
34import  re 
45import  sys 
56
67from  collections .abc  import  Mapping 
7- from  typing  import  TYPE_CHECKING , Any , ClassVar 
8+ from  typing  import  (
9+     TYPE_CHECKING , Any , ClassVar , Literal ,
10+ )
811
912import  numpy  as  np 
1013import  param 
1417
1518from  ..util  import  lazy_load 
1619from  .base  import  ModelPane 
20+ from  .image  import  PDF , SVG , Image 
21+ from  .markup  import  HTML , JSON 
1722
1823if  TYPE_CHECKING :
24+     import  narwhals  as  nw 
25+ 
1926    from  bokeh .document  import  Document 
2027    from  bokeh .model  import  Model 
2128    from  pyviz_comms  import  Comm 
2229
30+     VEGA_EXPORT_FORMATS  =  Literal ['png' , 'jpeg' , 'svg' , 'pdf' , 'html' , 'url' , 'scenegraph' ]
2331
2432def  ds_as_cds (dataset ):
2533    """ 
26-     Converts Vega dataset into Bokeh ColumnDataSource data 
34+     Converts Vega dataset into Bokeh ColumnDataSource data (Narwhals-compatible)  
2735    """ 
28-     import  pandas  as  pd 
29-     if  isinstance (dataset , pd .DataFrame ):
30-         return  {k : dataset [k ].values  for  k  in  dataset .columns }
36+     import  narwhals .stable .v2  as  nw 
37+     try :
38+         df  =  nw .from_native (dataset )
39+     except  TypeError :
40+         df  =  None 
41+     if  isinstance (df , (nw .DataFrame , nw .LazyFrame )):
42+         df  =  df .collect () if  isinstance (df , nw .LazyFrame ) else  df 
43+         return  {name : df [name ].to_numpy () for  name  in  df .columns }
44+ 
3145    if  len (dataset ) ==  0 :
3246        return  {}
33-     # create a list of unique keys from all items as some items may not include optional fields 
47+ 
48+     # Create a list of unique keys from all items as some items may not include optional fields 
3449    keys  =  sorted ({k  for  d  in  dataset  for  k  in  d .keys ()})
3550    data  =  {k : [] for  k  in  keys }
3651    for  item  in  dataset :
@@ -39,6 +54,50 @@ def ds_as_cds(dataset):
3954    data  =  {k : np .asarray (v ) for  k , v  in  data .items ()}
4055    return  data 
4156
57+ def  _is_dt_like (v ):
58+     return  (
59+         isinstance (v , (dt .date , dt .datetime , np .datetime64 ))
60+         or  (hasattr (v , "to_pydatetime" ) and  v .__class__ .__module__ .startswith ("pandas" ))
61+     )
62+ 
63+ def  _to_iso (v ):
64+     if  isinstance (v , (dt .datetime , dt .date )):
65+         return  v .isoformat ()
66+     if  isinstance (v , np .datetime64 ):
67+         # choose precision to taste: "s", "ms", "us", "ns" 
68+         return  np .datetime_as_string (v , unit = "s" )
69+     if  hasattr (v , "to_pydatetime" ) and  v .__class__ .__module__ .startswith ("pandas" ):
70+         return  v .to_pydatetime ().isoformat ()
71+     return  v 
72+ 
73+ def  _normalize_temporals_on_frame (df : nw .DataFrame ) ->  nw .DataFrame :
74+     import  narwhals .stable .v2  as  nw 
75+     overrides  =  {}
76+     ns  =  nw .get_native_namespace (df )
77+     for  col  in  df .columns :
78+         dtype  =  df [col ].dtype 
79+         if  dtype .is_temporal ():
80+             overrides [col ] =  df [col ].cast (nw .String )
81+         elif  dtype  ==  nw .Object  or  dtype  ==  nw .Unknown :
82+             vals  =  df [col ].to_list ()
83+             if  any (_is_dt_like (v ) for  v  in  vals ):
84+                 overrides [col ] =  nw .new_series (
85+                     name = col ,
86+                     values = [_to_iso (v ) for  v  in  vals ],
87+                     backend = ns 
88+                 )
89+     if  overrides :
90+         return  df .with_columns (** overrides )
91+     return  df 
92+ 
93+ def  ds_to_records (dataset : Any ) ->  list [dict [str , Any ]] |  None :
94+     import  narwhals .stable .v2  as  nw 
95+     try :
96+         df  =  nw .from_native (dataset )
97+     except  TypeError :
98+         return  None 
99+     df  =  _normalize_temporals_on_frame (df )
100+     return  df .rows (named = True )
42101
43102_containers  =  ['hconcat' , 'vconcat' , 'layer' ]
44103
@@ -218,6 +277,102 @@ def applies(cls, obj: Any) -> float | bool | None:
218277            return  True 
219278        return  cls .is_altair (obj )
220279
280+     def  export (
281+         self , fmt : VEGA_EXPORT_FORMATS , as_pane : bool  =  False , ** kwargs : dict 
282+     ) ->  bytes  |  str  |  dict  |  ModelPane :
283+         """ 
284+         Exports the Vega spec to various formats. 
285+ 
286+         The export method converts the Vega/Altair specification to different 
287+         output formats. It requires vl-convert-python to be installed. 
288+ 
289+         Parameters 
290+         ---------- 
291+         fmt : str 
292+             The format to export to. Must be one of 'png', 'jpeg', 'svg', 
293+             'pdf', 'html', 'url', 'scenegraph'. 
294+         as_pane : bool, default False 
295+             If True, wraps the exported data in the appropriate Panel pane. 
296+         **kwargs : dict 
297+             Additional keyword arguments passed to the vl-convert functions. 
298+ 
299+         Returns 
300+         ------- 
301+         bytes | str | ModelPane 
302+             The exported data in the requested format, or a Panel pane if 
303+             as_pane=True. 
304+ 
305+         Raises 
306+         ------ 
307+         ImportError 
308+             If vl-convert-python is not installed. 
309+         ValueError 
310+             If an unsupported format is specified. 
311+ 
312+         Examples 
313+         -------- 
314+         >>> vega_pane = Vega(spec_dict) 
315+         >>> png_bytes = vega_pane.export('png') 
316+         >>> image_pane = vega_pane.export('png', as_pane=True) 
317+         """ 
318+         try :
319+             import  vl_convert  as  vlc   # type: ignore[import-untyped] 
320+         except  ImportError :
321+             raise  ImportError (
322+                 'vl-convert-python is required to export Vega specs. ' 
323+                 'Please install it via `pip install vl-convert-python`.' 
324+             ) from  None 
325+ 
326+         spec  =  self .object  if  isinstance (self .object , dict ) else  self .object .to_dict ()
327+         spec  =  dict (spec )
328+         data  =  spec .get ('data' , {})
329+         if  isinstance (data , list ):
330+             converted  =  []
331+             for  datum  in  data :
332+                 if  isinstance (datum , dict ) and  'values'  in  datum :
333+                     records  =  ds_to_records (datum ['values' ])
334+                     if  records  is  not None :
335+                         datum  =  dict (datum , values = records )
336+                 converted .append (datum )
337+             spec ["data" ] =  converted 
338+         elif  isinstance (data , dict ) and  'values'  in  data :
339+             records  =  ds_to_records (data ['values' ])
340+             if  records  is  not None :
341+                 spec ["data" ] =  dict (data , values = records )
342+ 
343+         # Get dimensions from container or use spec 
344+         spec ['width' ] =  self .width  or  spec .get ("width" , 800 )
345+         spec ['height' ] =  self .height  or  spec .get ("height" , 600 )
346+ 
347+         if  'schema/vega/'  in  spec .get ('$schema' , 'schema/vega-lite/' ):
348+             src  =  'vega' 
349+         else :
350+             src  =  'vegalite' 
351+         fmt_lower  =  fmt .lower ()
352+         func_name  =  f"{ src } { fmt_lower }  
353+         func  =  getattr (vlc , func_name , None )
354+         if  func  is  None :
355+             raise  ValueError (
356+                 f'Unsupported format { fmt !r}  
357+                 f"'png', 'jpeg', 'svg', 'pdf', 'html', or 'url'." 
358+             )
359+         result  =  func (spec , ** kwargs )
360+         if  as_pane :
361+             params  =  {'width' : self .width , 'height' : self .height , 'sizing_mode' : self .sizing_mode }
362+             if  fmt_lower  ==  'svg' :
363+                 return  SVG (result , ** params )
364+             elif  fmt_lower  ==  'pdf' :
365+                 return  PDF (result , ** params )
366+             elif  fmt_lower  ==  'html' :
367+                 return  HTML (result , ** params )
368+             elif  fmt_lower  ==  'url' :
369+                 iframe_html  =  f'<iframe src="{ result }  
370+                 return  HTML (iframe_html , ** params )
371+             elif  fmt_lower  ==  'scenegraph' :
372+                 return  JSON (result )
373+             return  Image (result , ** params )
374+         return  result 
375+ 
221376    def  _get_sources (self , json , sources = None ):
222377        sources  =  {} if  sources  is  None  else  dict (sources )
223378        datasets  =  json .get ('datasets' , {})
0 commit comments