Skip to content

About

Blazor wrapper for html-to-image: capture any DOM element as PNG/JPEG/SVG/Blob. Ships the library and auto-registers it - no npm or manual script tags.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

32 Commits

Folders and files

Repository files navigation

"Buy Me A Coffee"

Blazor.HtmlToImage

NuGet Version NuGet Downloads Build codecov Storybook Last updated Twitter

Blazor wrapper for html-to-image: capture any DOM element as PNG, JPEG, SVG, raw bytes or pixel data.

The html-to-image build is vendored and pinned inside the package and injected by a Blazor JS initializer, so there is no npm step, no CDN, and no <script> tag to add. That matters beyond convenience: a CDN reference breaks a MAUI or hybrid app offline, lets a shipped store build change behaviour without a release, and counts as downloading executable code at runtime under App Store guideline 2.5.2.

Live storybook - interactive stories for every feature.

Installation

dotnet add package Kebechet.Blazor.HtmlToImage

Register the service:

using Kebechet.Blazor.HtmlToImage;

builder.Services.AddHtmlToImage();

Usage

@inject IHtmlToImageService _htmlToImage

<div @ref="_poster" class="poster">
    <h1>Bench press - 120 kg x 5</h1>
    <button class="capture-ignore" @onclick="Capture">Share</button>
</div>

@code {
    private ElementReference _poster;

    private async Task Capture()
    {
        var png = await _htmlToImage.ToPngBytesAsync(_poster, new HtmlToImageOptions
        {
            PixelRatio = 3,
            BackgroundColor = "#0b0b0b",
            ExcludeCssClasses = ["capture-ignore"],
        });

        // upload, save, or hand to a native share sheet
    }
}

Every method also takes a DOM id instead of an ElementReference:

var dataUrl = await _htmlToImage.ToPngAsync("poster");

Data URL vs. bytes

ToPngAsync returns a data:image/png;base64,... string, matching upstream. ToPngBytesAsync returns the raw byte[]. Prefer the bytes overload for anything you intend to upload or save: a poster at PixelRatio = 3 is several megabytes, and a data URL is the same image as base64 text - roughly 33% larger.

Both come back over a JS interop stream, never as a single interop message - which matters on Blazor Server, where one message is capped at 32 KB by default and a larger one closes the circuit. The largest result a capture may return is MaxResultBytes (default 32 MB).

In a MAUI BlazorWebView the stream does not avoid base64 entirely: the WebView bridge only carries text, so blazor.webview.js base64-encodes each chunk on its way across. Streaming there keeps every bridge message small; it does not remove the encoding.

Cancellation

Every method takes a CancellationToken. Cancelling stops the caller waiting; it does not stop the browser, because html-to-image has no abort - a cancelled capture still runs to completion (or to CaptureTimeoutMs) in the page and its result is discarded.

Hosting models

Blazor WebAssembly, Blazor Server (interactive server render mode) and Blazor Hybrid (MAUI BlazorWebView). The browser suite runs captures in a WebAssembly app and in a Server app pinned to SignalR's default 32 KB message limit; MAUI is verified on devices by hand.

Excluding elements from the capture

Upstream's filter is a JavaScript predicate, which cannot cross JS interop as a delegate without a round-trip per DOM node. It is modelled instead as two declarative options that the interop layer compiles into a single filter:

new HtmlToImageOptions
{
    ExcludeCssClasses = ["capture-ignore", "debug-overlay"],
    ExcludeSelector = "[data-private]",
}

Excluding a node excludes its whole subtree, matching upstream semantics.

Repeated captures of the same subtree

Font resolution dominates the cost of a capture that uses web fonts. Resolve once and reuse:

var fontCss = await _htmlToImage.GetFontEmbedCssAsync(_poster);

foreach (var frame in frames)
{
    var png = await _htmlToImage.ToPngBytesAsync(_poster, new HtmlToImageOptions
    {
        FontEmbedCss = fontCss,
    });
}

iOS/WebKit blank images and hangs - handled by default

Two Safari/WebKit failure modes are mitigated in the interop layer, on by default:

  • Blank large images. WebKit rasterises the capture's intermediate SVG before large embedded images have finished decoding, so on iOS the first capture of an image-heavy subtree comes back with those images blank - and a plain retry is not enough, because a single re-capture can stay blank too. Measured on a real iPhone: repeated captures converge on the complete image by the second or third attempt, while pre-decoding the embedded images does not help (WebKit's SVG-image loader does not share the page's decode cache). Captures therefore repeat until two consecutive results are identical, capped by StabilizeAttempts (default 3, set 1 to opt out). A stabilised capture of a heavy subtree costs roughly one extra attempt (~2x a single capture) on the first call.
  • Captures that never return. Safari rejects HTMLImageElement.decode() under memory pressure even for successfully loaded images, and upstream's createImage has no rejection path - the capture promise then never settles. Every capture attempt is bounded by CaptureTimeoutMs (default 30000): an attempt that hangs fails the capture with a descriptive error instead of hanging forever. The bound is per attempt, not per capture - a capture whose attempts each finish just inside it can take up to StabilizeAttempts x CaptureTimeoutMs (90 s by default).

Subtrees with genuinely dynamic content (a running clock, an animation) never produce two identical captures; they take StabilizeAttempts captures and return the last one. Set StabilizeAttempts = 1 for those.

Both mitigations are also proposed upstream: the never-settling capture as bubkoo/html-to-image#589 and the stabilisation loop as an opt-in stabilizationAttempts option in bubkoo/html-to-image#591. Once a vendored release contains 589, the timeout stays as a plain safety bound; once it contains 591, the interop's captureStable loop can be replaced by forwarding StabilizeAttempts to upstream's option.

Patched html-to-image

The vendored html-to-image.js is the official 1.11.13 build plus one change: the fix proposed in html-to-image pull request 602 for html-to-image issue 601, carried until an html-to-image release includes it. A banner comment on the bundle's first line marks it.

What it changes. A response that sends its Content-Type header twice reaches fetch() as one comma-joined value, such as image/png, image/png. html-to-image 1.11.13 split the resulting data URL on its first comma, so it embedded image/png;base64 instead of the image data, the image failed to load and the capture rejected with a bare [object Event]. The patched bundle keeps the first media type. Responses with a single Content-Type are unaffected.

Where it matters. .NET MAUI's BlazorWebView on Android sends Content-Type twice for every static file it serves to the WebView, so on Android any capture containing one of the app's own images failed. Only Chromium engines (desktop Chrome, Android WebView) keep the comma-joined value; WebKit parses it to a single media type, so iOS and Safari were never affected.

The patch is removed once an html-to-image release contains the fix. Until then, VendoredBundleTests fails if a bundle upgrade drops it, and PatchedLibraryTests captures an image served that way in a real browser.

Coverage vs. html-to-image 1.11.13

Complete. Every upstream entry point is reachable, and so is every option those entry points read.

Axis html-to-image This package
Entry points 7 7
Options 20 19

Entry points: toPng, toJpeg, toSvg, toPixelData, getFontEmbedCSS and toCanvas map to ToPngAsync, ToJpegAsync, ToSvgAsync, ToPixelDataAsync, GetFontEmbedCssAsync and ToCanvasAsync. toBlob's role is served by ToPngBytesAsync / ToJpegBytesAsync, which route through toCanvas to work around an upstream bug, and ToPixelDataAsync routes through toCanvas too - see the deviation notes below.

Options: 19 of 20. type is left out because only upstream's toBlob reads it, and this wrapper never calls toBlob (see the deviation below) - the method you call decides the format. Serialize_EveryMember_IsReadByTheInteropOrByUpstream keeps an option that nothing reads from coming back.

Three options are reshaped because their upstream form is a JavaScript value that cannot cross interop as data:

Upstream Here Why
filter ExcludeCssClasses, ExcludeSelector A JS predicate would need an interop round-trip per DOM node; these compile into one filter function
onImageErrorHandler ImageLoadFailed event A JS callback cannot be an option value; wired through a DotNetObjectReference only while subscribed
fetchRequestInit FetchRequestInit class Models the data-carrying members; signal, body and window are live JS objects with no data form

⚠️ ImageLoadFailed and ImagePlaceholder are alternatives, not complements. Upstream resolves a failed URL to the placeholder before assigning it to the cloned image, so with a placeholder set the clone loads successfully and the error never fires. Pick reporting or papering over. Pinned by ImageLoadFailed_ReportsAnImageThatCouldNotResolve.

Coverage is measured against html-to-image's published type definitions - lib/index.d.ts for entry points, lib/types.d.ts for the Options interface - excluding underscore-prefixed internals. Re-checked on every upstream version bump.

Two deliberate deviations from upstream

⚠️ ToPixelDataAsync does not call upstream's toPixelData, and returns PixelData rather than bare bytes.

Upstream's toPixelData reads getImageData at the element's CSS size from a canvas that is pixelRatio times larger, so on any high-density screen - or with PixelRatio or CanvasWidth set - it returns only the top-left corner, with no width to tell the rows apart. This wrapper reads the whole canvas and returns its Width, Height and Bytes (Width * Height * 4, RGBA, row by row). Pinned by Capture_PixelDataAtPixelRatioTwo_CoversTheWholeCanvas.

⚠️ ToJpegBytesAsync does not call upstream's toBlob.

Upstream's toBlob forwards its options to toCanvas but then calls its own internal canvasToBlob(canvas) with no options at all, so type and quality are silently dropped - asking it for a JPEG returns a PNG at quality 1. This wrapper calls toCanvas and does the canvas-to-blob step itself, which is what makes ToJpegBytesAsync return real JPEG bytes and Quality take effect. Pinned by the Capture_JpegStory_ReturnsBytesWithAJpegSignature browser test.

Upstreamed as bubkoo/html-to-image#590 - once that ships in a vendored release, the manual canvas-to-blob step in toStream can be replaced with upstream toBlob (keep the browser test either way).

Captures stall in hidden tabs - by upstream design

⚠️ A capture started while the tab is hidden or fully occluded makes no progress until the tab renders a frame again. If the tab becomes visible within CaptureTimeoutMs, the capture then completes normally. If it stays hidden longer, the attempt fails with the timeout error - the same applies to a MAUI app sent to the background mid-capture.

This is upstream's own resolution path, not wrapper behaviour: html-to-image's createImage resolves every capture inside img.decode().then(() => requestAnimationFrame(resolve)), and browsers do not fire requestAnimationFrame for hidden documents. Timers still fire there (throttled), which is why the timeout does. The normal case - capturing in response to a user action in a visible tab - never sees this. It matters only if a capture races a tab switch, or if you drive the page from automation that keeps it backgrounded (CDP screenshots force a frame, which un-stalls it).

Vendored library provenance

Version html-to-image 1.11.13, plus the patch in Patched html-to-image
File wwwroot/html-to-image.js
Source npm tarball html-to-image-1.11.13.tgz, package/dist/html-to-image.js
SHA-256 of the official file a90b42909d80964269ef6d5f3d1e4a5a7e2a4c263a5d2a76a9e7151901343262
SHA-256 of the shipped file 2e57a1a1d562f677adb7469006b6a4b9d8f5bed4646c6b882ae0956e4d7e87a8

The npm dist build is already minified - jsDelivr reports "skipped minification" for it - so it is vendored as is rather than re-minified. The official hash verifies byte-for-byte against the tarball; the shipped file differs from it only by the banner line and the patch.

Versioning

The first three parts of the version track the bundled html-to-image release - 1.11.13.x bundles html-to-image 1.11.13 - and the fourth is this wrapper's own revision. That leaves no part to signal a breaking change, so ⚠️ a revision can contain breaking changes to the wrapper's API; each release's notes list them under "Breaking".

License

MIT. html-to-image is itself MIT licensed.

About

Blazor wrapper for html-to-image: capture any DOM element as PNG/JPEG/SVG/Blob. Ships the library and auto-registers it - no npm or manual script tags.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages