Kebechet.Blazor.HtmlToImage
1.11.13.1
Prefix Reserved
dotnet add package Kebechet.Blazor.HtmlToImage --version 1.11.13.1
NuGet\Install-Package Kebechet.Blazor.HtmlToImage -Version 1.11.13.1
<PackageReference Include="Kebechet.Blazor.HtmlToImage" Version="1.11.13.1" />
<PackageVersion Include="Kebechet.Blazor.HtmlToImage" Version="1.11.13.1" />
<PackageReference Include="Kebechet.Blazor.HtmlToImage" />
paket add Kebechet.Blazor.HtmlToImage --version 1.11.13.1
#r "nuget: Kebechet.Blazor.HtmlToImage, 1.11.13.1"
#:package Kebechet.Blazor.HtmlToImage@1.11.13.1
#addin nuget:?package=Kebechet.Blazor.HtmlToImage&version=1.11.13.1
#tool nuget:?package=Kebechet.Blazor.HtmlToImage&version=1.11.13.1
Blazor.HtmlToImage
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 byte[] and streams the image over JS interop instead of marshalling base64. Prefer the bytes overload for anything you intend to upload or save: a poster at PixelRatio = 3 is several megabytes, and a data URL carries it as one JSON string - roughly 33% larger and fully buffered on both sides.
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'screateImagehas no rejection path - the capture promise then never settles. Every capture is bounded byCaptureTimeoutMs(default 30000) and fails with a descriptive error instead of hanging forever.
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
https://github.com/bubkoo/html-to-image/pull/589 and the stabilisation loop as an opt-in
stabilizationAttempts option in https://github.com/bubkoo/html-to-image/pull/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.
Coverage vs. html-to-image 1.11.13
Complete. Every upstream entry point and every option is reachable.
| Axis | html-to-image | This package |
|---|---|---|
| Entry points | 7 | 7 |
| Options | 20 | 20 |
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 - see the deviation note below.
Options: all 20, though three 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.
One deliberate deviation from upstream
⚠️ 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 https://github.com/bubkoo/html-to-image/pull/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 does not complete until the tab renders a frame again. Nothing errors and nothing times out - the Task just stays pending, then resolves when the tab becomes visible.
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. 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 |
| File | wwwroot/html-to-image.js |
| Source | npm tarball html-to-image-1.11.13.tgz, package/dist/html-to-image.js |
| SHA-256 | a90b42909d80964269ef6d5f3d1e4a5a7e2a4c263a5d2a76a9e7151901343262 |
The npm dist build is already minified - jsDelivr reports "skipped minification" for it - so it is vendored verbatim rather than re-minified, and the hash above verifies byte-for-byte against the tarball.
License
MIT. html-to-image is itself MIT licensed.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 is compatible. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.Components.Web (>= 10.0.0)
-
net6.0
- Microsoft.AspNetCore.Components.Web (>= 6.0.36)
-
net7.0
- Microsoft.AspNetCore.Components.Web (>= 7.0.20)
-
net8.0
- Microsoft.AspNetCore.Components.Web (>= 8.0.0)
-
net9.0
- Microsoft.AspNetCore.Components.Web (>= 9.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
Initial release. Wraps html-to-image 1.11.13 with complete coverage - all 7 entry points and all 20 options are reachable, over ElementReference or element id. ToPng/ToJpeg/ToSvg return data URLs; To*BytesAsync stream raw bytes over JS interop instead of base64; ToPixelDataAsync returns raw RGBA; ToCanvasAsync hands back the live HTMLCanvasElement; GetFontEmbedCssAsync enables font-CSS reuse across captures. Three options are reshaped because their upstream form is a JS value: filter becomes ExcludeCssClasses/ExcludeSelector, onImageErrorHandler becomes the ImageLoadFailed event, fetchRequestInit becomes a typed FetchRequestInit. JPEG bytes deliberately bypass upstream's toBlob, which drops type and quality. Verified by 13 real-browser Playwright tests plus unit tests.