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.
The package — JetsonPDF.OpenSilver. netstandard2.0 assembly with one entry point: XamlToPdfConverter.ConvertAsync(xaml, options) in the JetsonPDF.OpenSilver.Authoring namespace.
The host scaffold — JetsonPDF.OpenSilver.Sample.Host + .Browser + .Simulator. The minimal OpenSilver Application the converter runs inside, plus a JS bridge.
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.
Same XAML dialect — xmlns:jetsonpdf="http://schemas.jetsonpdf.com/authoring/2025", same <jetsonpdf:Document> / <jetsonpdf:Page> / annotations / form widgets / markup extensions as the WPF editor uses.
Async — OpenSilver BitmapImage loading is async (data-URI / HttpClient), so the converter is too.
netstandard2.0 — ships into any OpenSilver host (Wasm or Simulator).
Quick start
Add the package to an OpenSilver application project:
dotnet add package JetsonPDF.OpenSilver
Then call ConvertAsync from your page or component:
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.
Document structure — <jetsonpdf:Document> root with multiple <jetsonpdf:Page> children, per-page width / height / landscape, Title / Author metadata, named destinations, page labels.
Text — TextBlock with FontFamily, FontSize, FontStyle, FontWeight, Foreground, TextWrapping, TextAlignment, and mixed-style <Run> inlines.
Annotations — Link, TextMarkup (Highlight / Underline / StrikeOut / Squiggly with Target binding to a TextBlock for AFM quad derivation), FreeText, Stamp, Square, Circle, Line, Polygon, PolyLine, Ink. Cross-page Target references resolve through a coordinate prepass before the walk.
Form widgets — TextBox, CheckBox, ComboBox, ListBox, Button become real AcroForm fields when tagged with jetsonpdf:Form.FieldName="...". Form.MaxLength, IsMultiline, IsPassword, Action for tuning.
Page-context markup — {jetsonpdf:PageNumber} and {jetsonpdf:PageCount} resolve against a per-page DataContext. Run.Text isn't a DP in OpenSilver, so the markup extension emits a sentinel that the walker substitutes before Measure — layout sees the final string.
UIElement.Effect (DropShadow / Blur / shader) — rasterised via WriteableBitmap.Render and embedded as a PNG (descendants are skipped so baked pixels don't double-paint).
Base64 images — {jetsonpdf:Base64Image Data='...'} wraps the payload in a data: URI so OpenSilver's browser-backed BitmapImage decodes it natively. MIME is sniffed from the first base64 chars (PNG / JPEG / GIF).
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.
Internally it runs PdfmlRenderer → Reader → PdfToXamlConverter 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:
JetsonPDF.OpenSilver.Sample.Host — shared OpenSilver Application + MainPage UserControl with the hidden HostCanvas + [JSInvokable] bridge.
...Host.Browser — Wasm entry point + wwwroot/index.html. This is what a Playwright script navigates to.
...Host.Simulator — desktop runner backed by Edge WebView2 for stepping through the walker in a debugger.
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)
Convert(xaml) runs the full pipeline: walker → DocumentSnapshot → ConverterCore.Convert → Convert.ToBase64String. Returns the base64 PDF on success, or an "ERROR:..." sentinel on exception — no exceptions propagate across the JS boundary.
WalkToSnapshotJson(xaml) walks the tree but stops before PDF emission. The Tier-1 test suite uses this to diff the WPF and OpenSilver walkers' outputs against each other.
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));
The host panel must be live. It has to be parented in Application.Current's visual tree — OpenSilver's layout is DOM-driven, so a detached panel resolves to zero metrics and the converter would emit blank pages. It's sized per page, used as scratch space, and cleared afterwards (state restored even if an exception unwinds).
Raster images are composited onto the captured canvas directly, because html2canvas can't reliably snapshot OpenSilver's asynchronously-loaded <Image> elements.
Progress — the optional IProgress<TiffConversionProgress> sink reports each TiffConversionStage, suitable for driving a ProgressBar.
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
Async, not sync.ConvertAsync is asynchronous because OpenSilver's BitmapImage loading goes through data: URIs or HttpClient and resolves asynchronously. The WPF adapter is synchronous because PNG encoding happens in-process on raw pixel buffers; that's not how OpenSilver loads images.
WriteableBitmap.Render needs a live DOM. Image rasterisation and the effects-rasterisation path (DropShadow / Blur / shader) rely on WriteableBitmap.Render, which in the Wasm runtime needs the element parented to a live host Canvas. Set OpenSilverTreeWalker.HostContainer in your host's Loaded handler and the walker handles the parenting. In the Simulator or in unit tests that don't supply a host, rasterisation soft-fails and the image is omitted — layout still completes.
Standard-14 font metrics only. Font advance widths come from Adobe AFM tables (same Core 14 corpus as the WPF adapter). Custom FontFamily values resolve through OpenSilver to whatever the browser has installed, but the PDF still picks an AFM-metric font for the actual glyphs — embed a TrueType via the Writer if you need a non-standard face.
JPEG + SMask alpha. Composited correctly out of the box in any browser host: the default DefaultBrowserJpegDecoder bridges to the browser's own JPEG decoder via createImageBitmap. In non-browser hosts (Simulator before the JS bridge is up, unit tests) the alpha is silently dropped — matching the previous default. Swap in a managed decoder for better throughput on documents with many large JPEG+SMask images (see JPEG + SMask).
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.