OpenSilver

JetsonPDF in the browser. The OpenSilver adapter renders the same xmlns:jetsonpdf authoring-XAML dialect to PDF from inside a WebAssembly app, a WebView2 simulator, or a Playwright-driven Chromium CLI — no WPF, no STA thread, no Windows.

Two pieces.

API reference: jetsonpdf-opensilver-api.pdf (PDF→XAML + XAML→PDF converters, TiffViewer, PdfToTiffBrowserConverter, JPEG / JPX decoder hooks, widget action dispatcher) · jetsonpdf-tiff-api.pdf (the codec underneath TiffViewer).

Overview

JetsonPDF originally shipped a WPF adapter for the authoring-XAML pipeline. That works for Windows desktop apps, but it requires an STA thread and net8.0-windows — not an option for browser-hosted authoring tools, headless Linux servers, or CI pipelines. The OpenSilver adapter is the second walker for the same authoring dialect, running on the OpenSilver layout pass instead of the WPF one.

Both adapters feed the same runtime-neutral DocumentSnapshot into JetsonPDF.XamlToPdfConverter.Core. PDF emission is identical — the difference is just how the layout numbers get measured.

Quick start

Add the package to an OpenSilver application project:

dotnet add package JetsonPDF.OpenSilver

Then call ConvertAsync from your page or component:

using JetsonPDF.OpenSilver.Authoring;

string xaml = """
<jetsonpdf:Document xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:jetsonpdf="http://schemas.jetsonpdf.com/authoring/2025"
                    Title="OpenSilver demo">
  <jetsonpdf:Document.Pages>
    <jetsonpdf:Page Width="612" Height="792">
      <Canvas>
        <Rectangle Canvas.Left="72" Canvas.Top="72" Width="144" Height="72"
                   Fill="#1E88E5"/>
        <TextBlock Canvas.Left="72" Canvas.Top="160" Text="Hello from OpenSilver"
                   FontFamily="Helvetica" FontSize="18"/>
      </Canvas>
    </jetsonpdf:Page>
  </jetsonpdf:Document.Pages>
</jetsonpdf:Document>
""";

byte[] pdf = await XamlToPdfConverter.ConvertAsync(xaml);
// or: await XamlToPdfConverter.ConvertAsync(xaml, outputStream, options);

For the JS-driven path (Playwright-style harnesses, or any JavaScript caller), use the JS bridge exposed by the host project instead of calling ConvertAsync directly.

Supported XAML surface

The OpenSilver walker handles the full authoring dialect that the WPF adapter does, with two practical caveats noted under Caveats below.

PdfmlView — live data-bound PDFML

JetsonPDF.OpenSilver.PdfmlView is the browser flavour of the PDFML control — the same shared-source control that ships in JetsonPDF.Wpf, compiled for OpenSilver. Set Markup to a .pdfml document and Model to a data object; it renders the resulting PDF as a vector visual tree and re-renders automatically when the model raises INotifyPropertyChanged.

<UserControl xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:jp="clr-namespace:JetsonPDF.OpenSilver;assembly=JetsonPDF.OpenSilver">
  <jp:PdfmlView Markup="{Binding Template}" Model="{Binding Invoice}"/>
</UserControl>

Internally it runs PdfmlRendererReaderPdfToXamlConverter and hosts the result in a ScrollViewer — all in WebAssembly, no server round-trip. The only per-platform difference from the WPF flavour is the runtime XAML parser (XamlReader.Load here, XamlReader.Parse on WPF). Read-only PageCount / RenderError and Rendered / RenderFailed / WidgetActionInvoked round out the surface.

Hosting

OpenSilver's Measure / Arrange is partly DOM-driven. Calling those on a detached element in the Wasm runtime resolves to zero metrics, which means the walker would emit a blank page. Production hosts parent the parsed authoring tree to a hidden host Canvas for the duration of the walk:

// In your OpenSilver page's Loaded handler:
OpenSilverTreeWalker.HostContainer = HostCanvas;

After HostContainer is set, the walker attaches each parsed root, runs Measure / Arrange / UpdateLayout, walks the visual tree, and detaches before returning. The Simulator or unit-test contexts can leave HostContainer null and fall back to detached layout (some metrics resolve to zero, but the API still works).

The companion host project ships exactly that scaffold. See src/JetsonPDF.OpenSilver.Sample.Host/ for:

JS bridge

The host's JsBridge exposes two [JSInvokable] entry points to any JavaScript caller (Playwright, plain DotNet.invokeMethodAsync, hand-written JS). The host signals readiness via a global flag so harnesses can page.waitForFunction on it.

window.__jetsonpdfReady = true               // once the host is ready

DotNet.invokeMethodAsync(
    "JetsonPDF.OpenSilver.Sample.Host",
    "Convert",
    xamlString)                              // -> base64-encoded PDF bytes
                                             //    or "ERROR:<TypeName>:<Message>"

DotNet.invokeMethodAsync(
    "JetsonPDF.OpenSilver.Sample.Host",
    "WalkToSnapshotJson",
    xamlString)                              // -> SnapshotJson string
                                             //    (Tier-1 diff mode)

The Playwright-driven CLI at src/JetsonPDF.XamlToPdfConverter.Cli/ wires these calls together: launch Chromium, navigate to the hosted page, wait for __jetsonpdfReady, invoke Convert, decode the base64, write the PDF.

Browser-side PDF → TIFF

PdfToTiffBrowserConverter rasterizes every page of a PDF into a multipage TIFF without a server round-trip or a native image library — the whole pipeline runs in WebAssembly. It reads the PDF with JetsonPDF.Reader, turns each page into XAML via PdfToXamlConverter, mounts that into a hidden host Panel, snapshots the resulting DOM with html2canvas (fetched from a CDN on first use), reads the RGBA pixels back, and encodes the frames through JetsonPDF.Tiff's managed TiffWriter.

using JetsonPDF.OpenSilver;
using JetsonPDF.Tiff;

// HostCanvas is an empty Panel already parented in the live visual tree.
byte[] tiff = await PdfToTiffBrowserConverter.ConvertAsync(
    pdfBytes,
    HostCanvas,
    new TiffWriteOptions { Compression = TiffCompression.Deflate },
    progress: new Progress<TiffConversionProgress>(
        p => StatusText.Text = p.Description));

Browsers can't display TIFF natively, so pair this with JetsonPDF.Tiff's TiffImage.Decode + ToDataUri() to show the result in an <Image>. The JetsonPDF.OpenSilver.Showcase sample's TiffViewerPage does exactly that round-trip. For the Windows desktop equivalent, use the separate JetsonPDF.PdfToTiffConverter package.

Caveats

JPEG + SMask alpha

PDFs can pair a JPEG image with a soft-mask (/SMask) alpha channel. The browser's <img> element decodes the JPEG natively but ignores the external alpha, so the alpha has to be merged into the pixels before the image is handed to the renderer. The WPF adapter does this via JpegBitmapDecoder; OpenSilver targets netstandard2.0 and has no managed JPEG decoder in the BCL — even in .NET 8 or 9 there is none — so JetsonPDF bridges to the browser's own JPEG decoder via createImageBitmap + an offscreen <canvas>.

Default behavior: OpenSilverImageDecoders.Jpeg is wired to DefaultBrowserJpegDecoder.Instance on first use. In any browser host (Wasm, Edge WebView2, Playwright Chromium) JPEG+SMask alpha composites correctly without any consumer setup. In non-browser hosts (the OpenSilver Simulator before the JS bridge is up, unit tests, headless conversion) the first decode call hits a missing JS bridge; the default decoder swallows the exception, emits a one-time stderr warning, and returns null. The cache then falls back to passing the JPEG through with the alpha dropped — the same outcome as the pre-default behavior.

To plug in a managed decoder (recommended when a document has many large JPEG+SMask images — the default round-trips ~33 MB of base64 RGB across the JS bridge per 4K image):

using JetsonPDF.OpenSilver;

OpenSilverImageDecoders.Jpeg = new MyJpegDecoder();
OpenSilverImageDecoders.Jpx  = new MyJpxDecoder();   // JPEG 2000, no default

internal sealed class MyJpegDecoder : IJpegPixelDecoder
{
    public Task<JpegPixelResult?> TryDecodeRgb24Async(byte[] jpegBytes, CancellationToken ct = default)
    {
        // Wrap your decoder of choice (SkiaSharp, ImageSharp, libjpeg port, ...).
        // Output: width*height*3 bytes of R,G,B,R,G,B,.... Return null on failure.
        return Task.FromResult<JpegPixelResult?>(new JpegPixelResult(rgb, width, height));
    }
}

To disable JPEG+SMask compositing entirely and accept the alpha-drop pass-through:

OpenSilverImageDecoders.Jpeg = null;

JPX (JPEG 2000) has no default because browsers don't decode JP2 portably (Safari only). The hook is still there — assign an IJpxPixelDecoder via OpenSilverImageDecoders.Jpx if you need it.

API note: because the default decoder bridges through a JS Promise, IJpegPixelDecoder.TryDecodeRgb24Async returns a Task and PdfToXamlConverter.Convert is now PdfToXamlConverter.ConvertAsync(...) → Task<string>. The WPF flavour returns a synchronously-completed Task (no JS bridge involved), so blocking on the result via .GetAwaiter().GetResult() is safe in WPF; in OpenSilver, await it.

See the full feature matrix →