Rmp.DocProc.HtmlToPdf
1.1.2
dotnet add package Rmp.DocProc.HtmlToPdf --version 1.1.2
NuGet\Install-Package Rmp.DocProc.HtmlToPdf -Version 1.1.2
<PackageReference Include="Rmp.DocProc.HtmlToPdf" Version="1.1.2" />
<PackageVersion Include="Rmp.DocProc.HtmlToPdf" Version="1.1.2" />
<PackageReference Include="Rmp.DocProc.HtmlToPdf" />
paket add Rmp.DocProc.HtmlToPdf --version 1.1.2
#r "nuget: Rmp.DocProc.HtmlToPdf, 1.1.2"
#:package Rmp.DocProc.HtmlToPdf@1.1.2
#addin nuget:?package=Rmp.DocProc.HtmlToPdf&version=1.1.2
#tool nuget:?package=Rmp.DocProc.HtmlToPdf&version=1.1.2
Rmp.DocProc.HtmlToPdf
HTML → PDF / PNG conversion for .NET Framework 4.6.2+ legacy applications and modern .NET, using Playwright Chromium. Async-friendly and designed for queued jobs.
Package Map
Use this package as the canonical entry point for new work.
Rmp.DocProc.HtmlToPdf: supported package for app teams. Contains the public abstractions, dispatcher, DI, background worker, and the HTML → PDF / PNG handlers.Rmp.DocProc.Handlers.HtmlToPdf: deprecated low-level handler package. Keep only if you still have a downstream consumer that references the handler directly.EO.PDF: deprecated and removed from the new flow. Do not add new code against EO.PDF.
If you are planning a migration, the rule is simple: new code goes to Rmp.DocProc.HtmlToPdf; old EO.PDF code is only there to be deleted or replaced.
Current capability
- HTML file to PDF file conversion using Playwright Chromium.
- HTML file to PNG file conversion (scaffold for additional output formats).
- Optional relative URL normalization to absolute URLs with a provided base URL.
Built for legacy .NET applications
This package is meant to drop into large, long-lived .NET Framework applications (ASP.NET MVC 5, Web Forms, WCF services, Windows Services, console schedulers) just as easily as into modern .NET services. You get modern Chromium rendering without having to upgrade your runtime first.
| Target in the package | Who uses it |
|---|---|
net462 |
.NET Framework 4.6.2, 4.7.x, 4.8, 4.8.1 |
netstandard2.0 |
.NET Core 2.0+, .NET 5–9 (including .NET 8), Mono, other .NET Standard 2.0 hosts |
net10.0 |
Current .NET LTS release |
The full test suite runs on .NET Framework 4.8 on every release, as well as on .NET 10 and on .NET 8 (which exercises the netstandard2.0 build).
What this means for legacy apps:
- No Generic Host or DI container required. Create
HtmlToPdfConversionHandlerdirectly and call it (see below). The DI and background-worker helpers are optional extras. - Works with
packages.configand old-style.csprojfiles, as well asPackageReference. Turn on binding redirects (<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>) if your app does not already have them, because the package brings in currentMicrosoft.Extensions.*andSystem.*dependencies. - Fits existing job tables and schedulers. Keep your own backlog selection, status columns and retry logic. Use this package only for the HTML → PDF/PNG step. See Migrating from legacy EO PDF background jobs.
- Browser download can be switched off for locked-down servers. See Strict enterprise mode.
Minimal usage in a .NET Framework app (no DI)
using Rmp.DocProc.HtmlToPdf.Html;
// Create once and reuse (for example in a static field or your existing service locator).
private static readonly HtmlToPdfConversionHandler PdfHandler =
new HtmlToPdfConversionHandler(new HtmlToPdfOptions
{
BaseUrl = "https://intranet.example.com/",
AutoInstallBrowser = true
});
// Async code paths (MVC 5 async actions, Web Forms RegisterAsyncTask, async services):
byte[] pdf = await PdfHandler.ConvertHtmlToPdfAsync(html);
Calling from synchronous legacy code
Much older code cannot become async all the way up. From synchronous code, run the call on the thread pool and wait for it there. This avoids deadlocks under the ASP.NET (System.Web) and WinForms/WPF synchronization contexts:
byte[] pdf = Task.Run(() => PdfHandler.ConvertHtmlToPdfAsync(html))
.GetAwaiter()
.GetResult();
Do not call .Result or .Wait() directly on ConvertHtmlToPdfAsync(...) from a request or UI thread.
Server requirements
Rendering uses Playwright's Chromium, a separate browser process. That process needs Windows 10 / Windows Server 2016 or newer (or a supported Linux/macOS). The .NET Framework version of your app does not matter, but very old server operating systems such as Windows Server 2012 R2 cannot run current Chromium builds.
Install
dotnet add package Rmp.DocProc.HtmlToPdf
Browser provisioning (no manual install required by default)
The package tries to auto-install a Playwright browser at runtime if Chromium is missing.
- Default behavior: first run downloads Chromium automatically.
- Good for worker services where you want install-free onboarding.
- Requires network access on first run.
You can still preinstall manually if preferred:
pwsh bin/Debug/net10.0/playwright.ps1 install chromium
or for published artifacts:
pwsh playwright.ps1 install chromium
Important limitation:
- NuGet cannot practically bundle all OS-specific browser binaries inside a single package without major size and distribution drawbacks.
- The closest "bundled" experience is auto-provisioning on first run, or reusing an already installed browser executable/channel.
Usage (queued worker)
using Rmp.DocProc.HtmlToPdf.Abstractions;
using Rmp.DocProc.HtmlToPdf.Html;
using Rmp.DocProc.HtmlToPdf.Pipeline;
var handler = new HtmlToPdfConversionHandler(new HtmlToPdfOptions
{
BaseUrl = "https://example.com",
NormalizeRelativeUrls = true,
PrintBackground = true,
AutoInstallBrowser = true
});
var dispatcher = new ConversionDispatcher(new[] { handler });
var job = new ConversionJob(
SourcePath: "C:/queue/input/123.html",
DestinationPath: "C:/queue/output/123.pdf",
InputFormat: DocumentFormat.Html,
OutputFormat: DocumentFormat.Pdf,
Metadata: new Dictionary<string, string>
{
["jobId"] = "123"
});
await dispatcher.DispatchAsync(job);
Use system Chrome or Edge instead of Playwright-managed Chromium
If your environment already has a managed browser installation, point the handler to it:
var handler = new HtmlToPdfConversionHandler(new HtmlToPdfOptions
{
AutoInstallBrowser = false,
BrowserChannel = "msedge"
// or BrowserExecutablePath = "C:/Program Files/Google/Chrome/Application/chrome.exe"
});
Usage with DI and background worker
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Rmp.DocProc.HtmlToPdf.DependencyInjection;
using Rmp.DocProc.HtmlToPdf.Html;
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices(services =>
{
services
.AddRubiconDocProcHtmlToPdf(
pdfOptions: new HtmlToPdfOptions
{
BaseUrl = "https://example.com/assets/",
NormalizeRelativeUrls = true
},
imageOptions: new HtmlToImageOptions
{
Width = 1280,
Height = 1810
})
.AddRubiconDocProcBackgroundWorker(queueCapacity: 500);
})
.Build();
await host.RunAsync();
Queue producer code can enqueue ConversionJob values through IConversionJobQueue.
Hosting and scheduling
If you are running this in Azure App Service, the simplest shape is:
- one app process hosts the queue consumer (
ConversionWorker) - the same process optionally runs a scheduler that scans your DB and enqueues work
- the PDF conversion itself stays in
HtmlToPdfConversionHandler
Example Program.cs for an App Service or Worker Service host:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Rmp.DocProc.HtmlToPdf.Abstractions;
using Rmp.DocProc.HtmlToPdf.DependencyInjection;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddRubiconDocProcHtmlToPdf(
pdfOptions: new HtmlToPdfOptions
{
BaseUrl = builder.Configuration["HtmlToPdf:BaseUrl"],
NormalizeRelativeUrls = true,
AutoInstallBrowser = true
});
builder.Services.AddRubiconDocProcBackgroundWorker(queueCapacity: 250);
builder.Services.AddHostedService<PdfBacklogScheduler>();
var host = builder.Build();
await host.RunAsync();
Example scheduler that polls your DB and enqueues jobs on a timer:
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Rmp.DocProc.HtmlToPdf.Abstractions;
public sealed class PdfBacklogScheduler : BackgroundService
{
private readonly IConversionJobQueue _queue;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<PdfBacklogScheduler> _logger;
public PdfBacklogScheduler(
IConversionJobQueue queue,
IServiceScopeFactory scopeFactory,
ILogger<PdfBacklogScheduler> logger)
{
_queue = queue;
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
const string PdfContext = "PdfDocument";
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MyAppDbContext>();
var pending = await db.BackgroundQueues
.Where(item => item.Status == BackgroundQueue_Status.Initial && item.Context == PdfContext)
.OrderBy(item => item.CreateDate)
.Take(50)
.ToListAsync(stoppingToken);
foreach (var backlog in pending)
{
await _queue.EnqueueAsync(new ConversionJob(
SourcePath: backlog.SourceHtmlPath,
DestinationPath: backlog.DestinationPdfPath,
InputFormat: DocumentFormat.Html,
OutputFormat: DocumentFormat.Pdf,
Metadata: new Dictionary<string, string>
{
["backlogId"] = backlog.ID.ToString()
}), stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to schedule PDF backlog jobs.");
}
}
}
}
Practical guidance:
- If App Service runs only one instance, an in-process scheduler is fine.
- If you scale out to multiple instances, use a distributed lock or move scheduling to Azure Functions TimerTrigger / WebJob / a queue-triggered worker so you do not enqueue the same backlog twice.
- If you already have DB rows containing the HTML payload, keep the scheduler responsible only for selecting and enqueuing work; keep the conversion and PDF writeback in the handler layer.
If you do not need a scheduler inside App Service, a good alternative is:
- Azure Functions TimerTrigger: scans the backlog and enqueues jobs
- App Service or Container App: runs only
ConversionWorker - Azure Queue Storage / Service Bus: holds the job requests if you want a more durable queue than the in-memory queue included here
Azure Functions sample
If you want the scheduling and processing to live inside Azure Functions, the cleanest pattern is usually a TimerTrigger that scans the backlog and converts items directly. In that model, the function app is the host and the converter is used as a normal service inside the function invocation.
Important before you copy the sample:
ConvertHtmlToPdfAsync(...)exists onRmp.DocProc.HtmlToPdf.Html.HtmlToPdfConversionHandler(the canonical package), not onIConversionHandler.- The legacy
Rmp.DocProc.Handlers.HtmlToPdfpackage exposesHandleAsync(...)only.
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.Timer;
using System.Data;
using System.Text.Json;
using Dapper;
using Microsoft.Extensions.Logging;
using Rmp.DocProc.HtmlToPdf.Html;
public sealed class PdfBacklogTimerFunction
{
private readonly IDbConnection _connection;
private readonly HtmlToPdfOptions _pdfOptions;
private readonly ILogger<PdfBacklogTimerFunction> _logger;
public PdfBacklogTimerFunction(
IDbConnection connection,
HtmlToPdfOptions pdfOptions,
ILogger<PdfBacklogTimerFunction> logger)
{
_connection = connection;
_pdfOptions = pdfOptions;
_logger = logger;
}
[Function(nameof(PdfBacklogTimerFunction))]
public async Task RunAsync([TimerTrigger("0 */5 * * * *")] TimerInfo timerInfo, CancellationToken cancellationToken)
{
const string sql = """
SELECT TOP (25)
ID,
DataJSON,
Status,
Exception,
ProcessedDate
FROM BackgroundQueue
WHERE Status = @Status AND Context = @Context
ORDER BY CreateDate
""";
var pending = (await _connection.QueryAsync<PdfBacklogRow>(new CommandDefinition(
sql,
new
{
Status = (int)BackgroundQueue_Status.Initial,
Context = "PdfDocument"
},
cancellationToken: cancellationToken))).ToList();
foreach (var backlog in pending)
{
try
{
var handler = new HtmlToPdfConversionHandler(_pdfOptions);
var parameters = JsonSerializer.Deserialize<PdfDocumentParameters>(backlog.DataJSON)
?? throw new InvalidOperationException("DataJSON was null.");
var pdfBytes = await handler.ConvertHtmlToPdfAsync(
parameters.HtmlInput,
baseUrl: parameters.BaseUrl,
cancellationToken: cancellationToken);
await _connection.ExecuteAsync(new CommandDefinition(
"UPDATE CCMBinaryData SET Data = @Data WHERE ID = @RecordId",
new
{
Data = pdfBytes,
RecordId = parameters.Destination.RecordID
},
cancellationToken: cancellationToken));
await _connection.ExecuteAsync(new CommandDefinition(
"UPDATE BackgroundQueue SET ProcessedDate = @ProcessedDate, Status = @Status, [Exception] = NULL, FailuresJSON = NULL WHERE ID = @Id",
new
{
Id = backlog.ID,
ProcessedDate = DateTimeOffset.UtcNow,
Status = (int)BackgroundQueue_Status.Processed
},
cancellationToken: cancellationToken));
}
catch (Exception ex)
{
await _connection.ExecuteAsync(new CommandDefinition(
"UPDATE BackgroundQueue SET [Exception] = @Exception, ProcessedDate = @ProcessedDate, Status = @Status WHERE ID = @Id",
new
{
Id = backlog.ID,
Exception = ex.ToString(),
ProcessedDate = DateTimeOffset.UtcNow,
Status = (int)BackgroundQueue_Status.Error
},
cancellationToken: cancellationToken));
_logger.LogError(ex, "Failed to process backlog item {BacklogId}.", backlog.ID);
}
}
}
}
public sealed record PdfBacklogRow(int ID, string DataJSON);
What 0 */5 * * * * means:
- Azure Functions timer triggers use a 6-field NCRONTAB format:
second minute hour day month day-of-week. 0 */5 * * * *runs every 5 minutes, exactly at second 0.
If you want it to feel more continuous, use a shorter interval instead:
*/30 * * * * *runs every 30 seconds.0 */1 * * * *runs every minute.
If you need true continuous work, a timer is the wrong primitive. Use one of these instead:
- a queue-triggered Function that processes work as soon as it arrives
- a Service Bus / Storage Queue consumer for durable background work
- an App Service or Worker Service running
ConversionWorkercontinuously
Important Azure Functions note:
- Add the timer extension package to your function app:
Microsoft.Azure.Functions.Worker.Extensions.Timer. - The built-in in-memory
IConversionJobQueueis not a good fit for cross-invocation persistence in Functions. - For Azure Functions, prefer either direct conversion inside the timer function as shown above, or a durable queue such as Storage Queue / Service Bus if you want producer and consumer separated.
- If you split producer and consumer, the queue should live outside the process; do not rely on
InMemoryConversionJobQueuefor that setup.
Migrating from legacy EO PDF background jobs
The old PdfDocumentBackgroundGenerationJob mixed queue lifecycle management, HTML normalization, PDF generation, and DB writeback in one class.
Deprecation target:
- Stop calling
EO.Pdf.Runtime.AddLicense(...),EO.Base.Runtime.FlushLog(), andEO.Base.Runtime.Shutdown(). - Stop calling
ParentSitePdfWriter.MakePdf(...)andAdminSitePdfWriter.GeneratePDF(...). - Stop building new queue handlers around the legacy EO-backed background job.
With Rmp.DocProc.HtmlToPdf, split responsibilities:
- Keep your existing backlog selection/status transitions in your app layer.
- Use
HtmlToPdfConversionHandleronly for HTML → PDF conversion. - Keep destination persistence (for example
CCMBinaryData.Data) in your app layer.
Lightweight payload contract (consumer-owned)
If your old backlog payload model referenced EO.Pdf or System.Web.Routing, define a consumer-owned DTO in your app that only uses BCL types (string, int, Dictionary<string, string?>, etc.) and deserialize it with System.Text.Json.
Recommended mapping:
- Old
EO.Pdfbootstrap/shutdown: remove. - Old
ParentSitePdfWriter.MakePdf(...)/AdminSitePdfWriter.GeneratePDF(...): replace withHtmlToPdfConversionHandler.HandleAsync(...). - Old string replacement for absolute URLs: replace with
HtmlUrlNormalizer.NormalizeAsync(...)(or handler-level normalization via options). - Old queue error/status fields: keep exactly as-is in your own processing loop.
Example migration pattern (pseudo-realistic app code):
public async Task ProcessPdfBacklogAsync(BackgroundQueue backlog, CancellationToken ct)
{
backlog.ProcessingAttempts++;
backlog.ProcessingDate = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(ct);
MyPdfDocumentRequest parameters;
try
{
parameters = JsonSerializer.Deserialize<MyPdfDocumentRequest>(backlog.DataJSON)
?? throw new InvalidOperationException("DataJSON was null.");
}
catch (Exception ex)
{
await MarkErrorAsync(backlog, "Failed to parse parameters.", ex, ct);
return;
}
try
{
var handler = new HtmlToPdfConversionHandler(new HtmlToPdfOptions
{
// BaseUrl can stay null here and be supplied per-conversion below.
BaseUrl = null,
NormalizeRelativeUrls = true,
PrintBackground = true,
// For locked-down hosts use enterprise settings instead:
// AutoInstallBrowser = false,
// BrowserChannel = "msedge"
});
var pdfBytes = await handler.ConvertHtmlToPdfAsync(
parameters.HtmlInput,
baseUrl: parameters.BaseUrl,
cancellationToken: ct);
// Your old destination behavior stays in your application code.
var record = await _db.CCMBinaryDatas.SingleAsync(o => o.ID == parameters.Destination.RecordID, ct);
record.Data = pdfBytes;
backlog.ProcessedDate = DateTimeOffset.UtcNow;
backlog.Status = (int)BackgroundQueue_Status.Processed;
backlog.Exception = null;
backlog.FailuresJSON = null;
await _db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
await MarkErrorAsync(backlog, "Failed to generate/save PDF.", ex, ct);
}
}
Why this preserves legacy behavior:
- Attempts/status transitions remain under your existing DB transaction model.
- The converter is now isolated and testable.
- EO runtime/log lifecycle is removed and replaced by Playwright browser lifecycle per conversion.
- URL normalization is deterministic and explicit.
One-line migration rule:
- If the code still mentions
EO.PDF, it is legacy and should be deleted or replaced byHtmlToPdfConversionHandler/HtmlUrlNormalizer/ your own DB writeback.
Strict enterprise mode (no runtime downloads)
Use strict mode when hosts are locked down and browser binaries must be provisioned by ops.
using Rmp.DocProc.HtmlToPdf.DependencyInjection;
services
.AddRubiconDocProcHtmlToPdfEnterprise(
pdfOptions: new HtmlToPdfOptions
{
BrowserChannel = "msedge"
},
imageOptions: new HtmlToImageOptions
{
BrowserChannel = "msedge"
},
preflightBrowserOnStartup: true)
.AddRubiconDocProcBackgroundWorker();
What strict mode does:
- Forces
AutoInstallBrowser = falsefor registered handlers. - Optionally preflights browser launch at host startup.
- Throws a clear error if no runnable browser is available.
Relative URL handling
When BaseUrl is set and NormalizeRelativeUrls is true:
<base href="...">is injected/updated in<head>.- relative values in
src,href,poster,data, andsrcsetare rewritten to absolute URLs.
This supports the flow where a pre-rendered HTML file is queued and converted later.
Pre-queue normalization
If your producer service needs to rewrite relative URLs before the HTML is saved and queued, call HtmlUrlNormalizer.NormalizeAsync(...) directly and persist the returned HTML.
using Rmp.DocProc.HtmlToPdf.Html;
var normalizedHtml = await HtmlUrlNormalizer.NormalizeAsync(rawHtml, "https://example.com/base/");
await File.WriteAllTextAsync("C:/queue/input/123.html", normalizedHtml);
Support
Maintained by Rubicon Microproducts. Report bugs and questions at https://github.com/rubiconmp/public-issues.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. 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 was computed. 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 was computed. 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 was computed. 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. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 is compatible. net463 was computed. net47 was computed. net471 was computed. net472 was computed. net48 was computed. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETFramework 4.6.2
- AngleSharp (>= 1.10.0)
- Microsoft.Bcl.AsyncInterfaces (>= 10.0.14)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.14)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.14)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.14)
- Microsoft.Playwright (>= 1.64.1)
- System.Threading.Channels (>= 10.0.14)
-
.NETStandard 2.0
- AngleSharp (>= 1.10.0)
- Microsoft.Bcl.AsyncInterfaces (>= 10.0.14)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.14)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.14)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.14)
- Microsoft.Playwright (>= 1.64.1)
- System.Threading.Channels (>= 10.0.14)
-
net10.0
- AngleSharp (>= 1.10.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.14)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.14)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.14)
- Microsoft.Playwright (>= 1.64.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
1.1.2: Dropped the net8.0 build (.NET 8 is out of support; .NET 8 apps now use the netstandard2.0 build). Conversions now share one Playwright driver per process instead of starting and stopping one per conversion: faster, and avoids a Windows hang when the driver is disposed (microsoft/playwright-dotnet#3419). Fixed .NET Framework apps hanging when starting a conversion if the console code page is UTF-8 (65001), as on GitHub Actions Windows runners or servers using UTF-8 as the system code page. Fixed root-relative links (e.g. /docs/x) not being resolved against BaseUrl on Linux and macOS. Fixed FileNotFoundException for Microsoft.Bcl.AsyncInterfaces when the netstandard2.0 build is used on .NET Core 3.1 / .NET 5-9. Package metadata aligned with other Rubicon Microproducts packages. 1.1.1: Documentation and package metadata for legacy .NET Framework apps (no-DI usage, calling from synchronous code, packages.config, server requirements). 1.1.0: Added net462 and netstandard2.0 targets so heavy legacy .NET Framework 4.6.2-4.8.1 apps can use the packages without upgrading their runtime; updated all dependencies.