Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to **dotnet-matplotlib** are recorded here. The project foll
[Semantic Versioning](https://semver.org/). While on `0.x` (pre-1.0), minor releases
may contain breaking changes.

## [Unreleased]

### Added

- **Verso notebook support**, shipped inside the existing `DotnetMatplotlib.Interactive`
package. A `[VersoExtension]`-marked `IDataFormatter` renders a `Figure` or `Plt`
returned from a cell as inline SVG (`image/svg+xml`), discovered automatically by the
Verso host (no registration call). [Verso](https://versonotebooks.com/) is the
actively-developed successor to the now-deprecated .NET Interactive / Polyglot
Notebooks ([#1](https://github.com/gnrkr789/dotnet-matplotlib/issues/1)).

## [0.0.9] — 2026-06-20

### Changed — ⚠️ BREAKING
Expand Down
1 change: 1 addition & 0 deletions PORTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ and is not part of this repository (it is git-ignored).
| 2026-06-15 | 36 | `axes.twinx` (shared x, independent right-hand y) | `Domain/Axes` (`SharedXFrom`, shared-x in `BuildContext`), `Domain/Figure` (`AddTwinX`), `Matplotlib/Plt` (`TwinX`), `samples/Gallery` | a twin overlay axes borrows the source axes' x range/scale at render time (`SharedXFrom`), draws a transparent background and only its right y-spine/ticks, and keeps an independent y scale; Gallery `twinx` PNG |
| 2026-06-15 | 37 | `streamplot` rewritten: Euler → RK4 + density occupancy mask | `Domain/Axes` (`Streamplot`), `samples/Gallery` | streamlines integrate the unit-speed field with classic 4th-order Runge–Kutta (forward + backward from each seed), so trajectories stay on curved/closed field lines (a circular field keeps constant radius, verified). A density occupancy mask claims the cells each line passes through and stops a line that enters another's territory or closes a loop; too-short trajectories release their cells — giving evenly spaced, non-overlapping lines (verified on a uniform field). Gallery `streamplot` PNG |
| 2026-06-15 | 38 | `contourf` filled bands (cell quantization → iso-band polygons) | `Domain/Axes` (`Contourf`), `samples/Gallery` | each grid cell is clipped to every band it overlaps with Sutherland–Hodgman against the two band-edge levels, emitting filled polygons whose boundaries follow the contour lines (verified: a band edge lands on the interpolated contour, not a grid line). Replaces the blocky per-cell image; now uses node coordinates `[0,cols-1]×[0,rows-1]` matching `contour`; Gallery `contourf` PNG |
| 2026-06-28 | 39 | Verso notebook formatters (`IDataFormatter`) | `Matplotlib.Interactive/Verso` (`MatplotlibFormatter`, package `DotnetMatplotlib.Interactive`) | inline SVG rendering of `Figure`/`Plt` in [Verso](https://versonotebooks.com/) notebooks: a `[VersoExtension]`-marked `IDataFormatter` returns `image/svg+xml`, auto-discovered by the host (no registration call). Ships in the existing `DotnetMatplotlib.Interactive` package alongside the .NET Interactive formatters, since Verso is its successor ([#1](https://github.com/gnrkr789/dotnet-matplotlib/issues/1)) |

Known deviations / TODO (in progress — see the README *Roadmap*; marked with
`TODO` comments in the source):
Expand Down
25 changes: 24 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,30 @@ plt.Show() // opens a window and blocks until it is closed; resizes r

## Notebooks

In a .NET Interactive / Polyglot / Jupyter notebook, figures render inline as SVG:
The `DotnetMatplotlib.Interactive` package renders a returned `Figure` / `Plt` inline as
SVG in both **Verso** and **.NET Interactive** notebooks.

### Verso

[Verso](https://versonotebooks.com/) is the actively-developed successor to .NET
Interactive. Add `DotnetMatplotlib.Interactive` as a Verso extension — the formatter is
discovered automatically, with no registration call:

```fsharp
open Matplotlib

let plt = Plt()
plt.Plot([| 0.0; 1.0; 2.0 |], [| 0.0; 1.0; 4.0 |], color = "C0") |> ignore
plt // the cell value renders as an inline SVG plot
```

See the [Verso extension docs](https://github.com/DataficationSDK/Verso/tree/main/docs/extensions)
for adding an extension package to your environment.

### .NET Interactive (legacy)

> .NET Interactive / Polyglot Notebooks is being retired — prefer Verso above. The same
> package still supports it with a one-time `register()`:

```fsharp
#r "nuget: DotnetMatplotlib.Interactive"
Expand Down
8 changes: 5 additions & 3 deletions nuget/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,12 @@ plt.SaveGif("wave.gif", 30, (fun i -> buildFrame i), fps = 20)

## Notebooks

In a .NET Interactive / Polyglot / Jupyter notebook, add
`DotnetMatplotlib.Interactive` renders figures inline as SVG in **Verso** and **.NET
Interactive** notebooks. In [Verso](https://versonotebooks.com/) (the actively-developed
successor to .NET Interactive) the formatter is discovered automatically — just add the
package as an extension. For legacy .NET Interactive / Polyglot, add
`#r "nuget: DotnetMatplotlib.Interactive"` and call
`Matplotlib.Interactive.Notebook.register ()` once — figures then render inline
as SVG.
`Matplotlib.Interactive.Notebook.register ()` once.

## DataFrames

Expand Down
6 changes: 4 additions & 2 deletions src/Matplotlib.Interactive/Matplotlib.Interactive.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
<RootNamespace>Matplotlib.Interactive</RootNamespace>
<AssemblyName>Matplotlib.Interactive</AssemblyName>
<PackageId>DotnetMatplotlib.Interactive</PackageId>
<Description>.NET Interactive / Polyglot / Jupyter notebook integration for DotnetMatplotlib: inline SVG rendering of figures in notebook cells.</Description>
<PackageTags>matplotlib;plotting;notebook;dotnet-interactive;polyglot;jupyter;fsharp</PackageTags>
<Description>Notebook integration for DotnetMatplotlib: inline SVG rendering of figures in .NET Interactive / Polyglot / Jupyter and Verso notebook cells. The Verso formatter is auto-discovered; .NET Interactive needs a one-time register().</Description>
<PackageTags>matplotlib;plotting;notebook;dotnet-interactive;polyglot;jupyter;verso;fsharp</PackageTags>
<!-- Microsoft.DotNet.Interactive.Formatting only ships prerelease builds. -->
<NoWarn>$(NoWarn);NU5104</NoWarn>
</PropertyGroup>

<ItemGroup>
<Compile Include="Notebook.fs" />
<Compile Include="Verso.fs" />
</ItemGroup>

<ItemGroup>
Expand All @@ -24,6 +25,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.DotNet.Interactive.Formatting" Version="1.0.0-beta.26120.1" />
<PackageReference Include="Verso.Abstractions" Version="1.0.22" />
</ItemGroup>

</Project>
84 changes: 84 additions & 0 deletions src/Matplotlib.Interactive/Verso.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
namespace Matplotlib.Verso

open System
open System.Collections.Generic
open System.Reflection
open System.Runtime.Loader
open System.Threading.Tasks
open Verso.Abstractions

/// <summary>
/// Verso notebook integration: renders a Matplotlib <c>Plt</c> or <c>Figure</c>
/// returned from a cell as inline SVG (<c>image/svg+xml</c>).
/// </summary>
/// <remarks>
/// Ships in the <c>DotnetMatplotlib.Interactive</c> package and is discovered
/// automatically via <c>[VersoExtension]</c>. Verso loads each extension into its own
/// collectible <c>AssemblyLoadContext</c> and shares only <c>Verso.Abstractions</c> with
/// the host, so the <c>Plt</c>/<c>Figure</c> a notebook kernel produces are *different
/// CLR types* from the ones this assembly references. The formatter therefore matches
/// values by type name and renders through reflection rather than a direct cast, so it
/// works across the load-context boundary.
/// </remarks>
[<VersoExtension; Sealed>]
type MatplotlibFormatter() =

// The host pre-filters with `SupportedTypes.Any(t -> t.IsInstanceOfType(value))`.
// Across the load-context boundary only the shared `System.Object` matches every
// value, so we advertise that and let CanFormat do the real, name-based gating.
static let supportedTypes = [| typeof<obj> |] :> IReadOnlyList<Type>

static let isRenderable (value: obj) : bool =
not (isNull value)
&& (match value.GetType().FullName with
| "Matplotlib.Plt"
| "Matplotlib.Domain.Figure" -> true
| _ -> false)

/// Render a value to SVG via reflection, so its type may come from any load context.
static let tryRenderSvg (value: obj) : string option =
try
let vt = value.GetType()
let toSvg = vt.GetMethod("ToSvg", Type.EmptyTypes)

if not (isNull toSvg) && toSvg.ReturnType = typeof<string> then
// A `Plt` (and anything exposing `string ToSvg()`) renders itself.
Some(toSvg.Invoke(value, null) :?> string)
elif vt.FullName = "Matplotlib.Domain.Figure" then
// A bare `Figure` renders through `FigureCanvas`, loaded from the value's
// own context so the constructed canvas accepts it.
let alc =
match AssemblyLoadContext.GetLoadContext vt.Assembly with
| null -> AssemblyLoadContext.Default
| ctx -> ctx

let backends = alc.LoadFromAssemblyName(AssemblyName "Matplotlib.Backends")
let canvasType = backends.GetType "Matplotlib.Backends.FigureCanvas"
let canvas = Activator.CreateInstance(canvasType, [| value |])
Some(canvasType.GetMethod("RenderToSvg", Type.EmptyTypes).Invoke(canvas, null) :?> string)
else
None
with _ ->
None

interface IDataFormatter with
member _.SupportedTypes = supportedTypes

// High enough to win over the generic object-tree fallback for our types.
member _.Priority = 100

member _.CanFormat(value: obj, _context: IFormatterContext) = isRenderable value

member _.FormatAsync(value: obj, _context: IFormatterContext) : Task<CellOutput> =
match tryRenderSvg value with
| Some svg -> Task.FromResult(CellOutput.Svg svg)
| None -> Task.FromResult(CellOutput.Error "dotnet-matplotlib: could not render the value to SVG.")

interface IExtension with
member _.ExtensionId = "com.dotnetmatplotlib.verso"
member _.Name = "dotnet-matplotlib"
member _.Version = "1.0.0"
member _.Author = "Jun Tae Kim"
member _.Description = "Renders Matplotlib figures (Plt and Figure) as inline SVG in Verso notebooks."
member _.OnLoadedAsync(_context: IExtensionHostContext) : Task = Task.CompletedTask
member _.OnUnloadedAsync() : Task = Task.CompletedTask
3 changes: 3 additions & 0 deletions tests/Matplotlib.Tests/Matplotlib.Tests.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,15 @@
<Compile Include="DataFrameTests.fs" />
<Compile Include="ReportTests.fs" />
<Compile Include="TableTests.fs" />
<Compile Include="VersoTests.fs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<!-- Verso's stub contexts / test doubles for verifying the IDataFormatter. -->
<PackageReference Include="Verso.Testing" Version="1.0.22" />
</ItemGroup>

<ItemGroup>
Expand Down
83 changes: 83 additions & 0 deletions tests/Matplotlib.Tests/VersoTests.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
namespace Matplotlib.Tests

open System
open Xunit
open Verso.Abstractions
open Verso.Testing.Stubs
open Matplotlib
open Matplotlib.Domain
open Matplotlib.Verso

module VersoTests =

let private formatter = MatplotlibFormatter() :> IDataFormatter

// The real context type the Verso host passes to formatters (from Verso.Testing).
let private ctx = StubFormatterContext() :> IFormatterContext

[<Fact>]
let ``formatter claims Plt and Figure via CanFormat (object catch-all + name gate)`` () =
// Across Verso's load-context boundary only System.Object matches the host's
// IsInstanceOfType pre-filter, so SupportedTypes is the catch-all and CanFormat
// does the real, name-based gating.
Assert.Contains(typeof<obj>, formatter.SupportedTypes)
let plt = Plt()
plt.Plot([| 0.0; 1.0 |], [| 0.0; 1.0 |]) |> ignore
Assert.True(formatter.CanFormat(plt, ctx))
Assert.True(formatter.CanFormat(plt.CurrentFigure(), ctx))

[<Fact>]
let ``FormatAsync renders a Figure as image/svg+xml`` () =
let plt = Plt()
plt.Plot([| 0.0; 1.0; 2.0 |], [| 0.0; 1.0; 4.0 |], color = "C0") |> ignore
let fig = plt.CurrentFigure()
Assert.True(formatter.CanFormat(fig, ctx))
let out = formatter.FormatAsync(fig, ctx).Result
Assert.Equal("image/svg+xml", out.MimeType)
Assert.Contains("<svg", out.Content)
Assert.False out.IsError

[<Fact>]
let ``FormatAsync renders a Plt facade as SVG`` () =
let plt = Plt()
plt.Plot([| 0.0; 1.0 |], [| 0.0; 1.0 |]) |> ignore
Assert.True(formatter.CanFormat(plt, ctx))
let out = formatter.FormatAsync(plt, ctx).Result
Assert.Equal("image/svg+xml", out.MimeType)
Assert.Contains("<svg", out.Content)

[<Fact>]
let ``formatter does not claim unrelated values`` () =
Assert.False(formatter.CanFormat("just a string", ctx))

/// Replicates the Verso host's discovery pipeline against our shipped assembly:
/// scan for `[VersoExtension]` types implementing `IDataFormatter`, construct via
/// `Activator.CreateInstance` (as the host does), then format a real figure. This
/// proves the integration path end-to-end short of launching the host process.
[<Fact>]
let ``host discovery path finds, constructs and runs the formatter`` () =
let assembly = typeof<MatplotlibFormatter>.Assembly

let discovered =
assembly.GetTypes()
|> Array.filter (fun t ->
t.GetCustomAttributes(typeof<VersoExtensionAttribute>, false).Length > 0
&& typeof<IDataFormatter>.IsAssignableFrom t
&& not t.IsAbstract)

Assert.Contains(typeof<MatplotlibFormatter>, discovered)

// The host instantiates discovered extensions with their parameterless ctor.
let instance = Activator.CreateInstance(typeof<MatplotlibFormatter>) :?> IDataFormatter

let plt = Plt()
plt.Plot([| 0.0; 1.0; 2.0 |], [| 0.0; 1.0; 4.0 |], color = "C0") |> ignore

Assert.True(instance.CanFormat(plt, ctx))
let out = instance.FormatAsync(plt, ctx).Result
Assert.Equal("image/svg+xml", out.MimeType)
Assert.Contains("<svg", out.Content)

// Identity the host surfaces in its extension list must be non-empty.
let ext = instance :> IExtension
Assert.False(String.IsNullOrWhiteSpace ext.ExtensionId)
Loading