Rmp.DocProc.HtmlToPdf 1.0.3

dotnet add package Rmp.DocProc.HtmlToPdf --version 1.0.3
                    
NuGet\Install-Package Rmp.DocProc.HtmlToPdf -Version 1.0.3
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Rmp.DocProc.HtmlToPdf" Version="1.0.3" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Rmp.DocProc.HtmlToPdf" Version="1.0.3" />
                    
Directory.Packages.props
<PackageReference Include="Rmp.DocProc.HtmlToPdf" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Rmp.DocProc.HtmlToPdf --version 1.0.3
                    
#r "nuget: Rmp.DocProc.HtmlToPdf, 1.0.3"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Rmp.DocProc.HtmlToPdf@1.0.3
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Rmp.DocProc.HtmlToPdf&version=1.0.3
                    
Install as a Cake Addin
#tool nuget:?package=Rmp.DocProc.HtmlToPdf&version=1.0.3
                    
Install as a Cake Tool

Rmp.DocProc.HtmlToPdf

Async-friendly document formatting package, 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: legacy 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.

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/net8.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 on Rmp.DocProc.HtmlToPdf.Html.HtmlToPdfConversionHandler (the canonical package), not on IConversionHandler.
  • The legacy Rmp.DocProc.Handlers.HtmlToPdf package exposes HandleAsync(...) 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 ConversionWorker continuously

Important Azure Functions note:

  • Add the timer extension package to your function app: Microsoft.Azure.Functions.Worker.Extensions.Timer.
  • The built-in in-memory IConversionJobQueue is 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 InMemoryConversionJobQueue for 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(), and EO.Base.Runtime.Shutdown().
  • Stop calling ParentSitePdfWriter.MakePdf(...) and AdminSitePdfWriter.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 HtmlToPdfConversionHandler only 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.Pdf bootstrap/shutdown: remove.
  • Old ParentSitePdfWriter.MakePdf(...) / AdminSitePdfWriter.GeneratePDF(...): replace with HtmlToPdfConversionHandler.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 by HtmlToPdfConversionHandler / 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 = false for 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, and srcset are 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);
Product Compatible and additional computed target framework versions.
.NET 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.3 71 8/21/2026
1.0.1 137 6/30/2026
1.0.0 110 5/20/2026