< Summary - ZhEaIsNsAaBn.Libraries coverage (affected: 23 packages)

Information
Class: ZhEaIsNsAaBn.Utilities.PrintPDF.ChromiumHandler
Assembly: ZhEaIsNsAaBn.Utilities.PrintPDF
File(s): /home/runner/work/ZhEaIsNsAaBn.Libraries.V1/ZhEaIsNsAaBn.Libraries.V1/src/UtilitiesPackages/ZhEaIsNsAaBn.Utilities.PrintPDF/ChromiumHandler.cs
Line coverage
90%
Covered lines: 90
Uncovered lines: 9
Coverable lines: 99
Total lines: 256
Line coverage: 90.9%
Branch coverage
85%
Covered branches: 29
Total branches: 34
Branch coverage: 85.2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%22100%
DisposeAsync()100%44100%
Dispose()100%210%
PrintPdfFromViewAsync()100%210%
PrintPdfFromHtmlAsync(...)100%11100%
PrintPdfFromUrlAsync(...)100%22100%
Dispose(...)50%66100%
InitializeAsync()100%66100%
DownloadAsync()100%11100%
AcquireDownloadLockAsync()100%11100%
PrintPdfAsync()100%1175%
PrintAsync()100%11100%
PrintPageAsync()83.33%66100%
IsAllowed(...)87.5%88100%
Viewport(...)100%11100%

File(s)

/home/runner/work/ZhEaIsNsAaBn.Libraries.V1/ZhEaIsNsAaBn.Libraries.V1/src/UtilitiesPackages/ZhEaIsNsAaBn.Utilities.PrintPDF/ChromiumHandler.cs

#LineLine coverage
 1namespace ZhEaIsNsAaBn.Utilities.PrintPDF;
 2
 3/// <summary>
 4/// Prints HTML, a URL or a Razor view to PDF with headless Chromium. One shared browser per handler, started on first u
 5/// (register the handler as a singleton); disposing the handler closes the browser. Failures are returned as
 6/// <see cref="PrintPdfErrors"/>; cancellation propagates.
 7/// </summary>
 8public class ChromiumHandler : IAsyncDisposable, IDisposable
 9{
 210    private static readonly TimeSpan _downloadLockRetry = TimeSpan.FromMilliseconds( 250 );
 11
 3812    private readonly SemaphoreSlim   _browserInitializeLock = new( 1, 1 );
 13    private readonly ChromiumOptions _options;
 14    private          bool            _disposed;
 15
 16
 17    /// <summary>Creates the handler.</summary>
 18    /// <param name="options">Where the browser comes from; <see langword="null"/> downloads Chromium to <see cref="Defa
 7619    public ChromiumHandler( ChromiumOptions? options = null ) => _options = options ?? new ChromiumOptions();
 20
 21
 22    /// <summary>Where Chromium is downloaded (once) and launched from: <c>%TEMP%/ZhEaIsNsAaBn.PrintPDF/Chrome</c>.</sum
 223    public static string DefaultDownloadPath { get; } = Path.Combine( Path.GetTempPath(), "ZhEaIsNsAaBn.PrintPDF", "Chro
 24
 25
 26    private IBrowser? Browser { get; set; }
 27
 28
 29    /// <summary>Closes the browser.</summary>
 30    /// <returns>A task that completes when the browser is closed.</returns>
 31    public async ValueTask DisposeAsync()
 32    {
 5633        if ( !_disposed && Browser is not null ) { await Browser.DisposeAsync().ConfigureAwait( false ); }
 34
 35
 3836        Browser = null;
 3837        Dispose( true );
 3838        GC.SuppressFinalize( this );
 3839    }
 40
 41    /// <summary>Closes the browser.</summary>
 42    public void Dispose()
 43    {
 044        Dispose( true );
 045        GC.SuppressFinalize( this );
 046    }
 47
 48    /// <summary>Renders <paramref name="view"/> to HTML and prints it.</summary>
 49    /// <param name="view">The view to render.</param>
 50    /// <param name="context">The action context the view is rendered in.</param>
 51    /// <param name="options">Page and PDF options.</param>
 52    /// <param name="cancellationToken">Token to cancel the print.</param>
 53    /// <returns>The PDF bytes, or <see cref="PrintPdfErrors.BrowserNotStarted"/> / <see cref="PrintPdfErrors.PrintFaile
 54    /// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
 55    public async Task<LayerResponse<byte[]>> PrintPdfFromViewAsync( ViewAsPdf view, ActionContext context, PrintOptions 
 56    {
 057        ArgumentNullException.ThrowIfNull( view );
 58
 059        var html = await view.GetHtmlAsStringAsync( context ).ConfigureAwait( false );
 60
 61
 062        return await PrintPdfAsync( options, null, html, cancellationToken ).ConfigureAwait( false );
 063    }
 64
 65    /// <summary>Prints <paramref name="htmlData"/>.</summary>
 66    /// <param name="htmlData">The HTML document.</param>
 67    /// <param name="options">Page and PDF options.</param>
 68    /// <param name="cancellationToken">Token to cancel the print.</param>
 69    /// <returns>The PDF bytes, or <see cref="PrintPdfErrors.BrowserNotStarted"/> / <see cref="PrintPdfErrors.PrintFaile
 70    /// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
 71    public Task<LayerResponse<byte[]>> PrintPdfFromHtmlAsync( string htmlData, PrintOptions options, CancellationToken c
 3272        => PrintPdfAsync( options, null, htmlData, cancellationToken );
 73
 74    /// <summary>
 75    /// Opens <paramref name="url"/> and prints it. Only <c>http</c>/<c>https</c> URLs are opened, and only hosts in
 76    /// <see cref="ChromiumOptions.AllowedHosts"/> when that list isn't empty.
 77    /// </summary>
 78    /// <remarks>
 79    /// <b>Never pass a user-controlled URL.</b> The browser runs on the server and can reach internal addresses (SSRF);
 80    /// the URL yourself and set <see cref="ChromiumOptions.AllowedHosts"/>.
 81    /// </remarks>
 82    /// <param name="url">The page to print.</param>
 83    /// <param name="options">Page and PDF options.</param>
 84    /// <param name="cancellationToken">Token to cancel the print.</param>
 85    /// <returns>
 86    /// The PDF bytes, or <see cref="PrintPdfErrors.UrlNotAllowed"/>, <see cref="PrintPdfErrors.BrowserNotStarted"/> or
 87    /// <see cref="PrintPdfErrors.PrintFailed"/>.
 88    /// </returns>
 89    /// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
 90    public Task<LayerResponse<byte[]>> PrintPdfFromUrlAsync( Uri url, PrintOptions options, CancellationToken cancellati
 91    {
 1892        ArgumentNullException.ThrowIfNull( url );
 93
 3294        if ( !IsAllowed( url ) ) { return Task.FromResult<LayerResponse<byte[]>>( PrintPdfErrors.UrlNotAllowed( url ) );
 95
 96
 497        return PrintPdfAsync( options, url.AbsoluteUri, null, cancellationToken );
 98    }
 99
 100    /// <summary>Releases the browser and the lock.</summary>
 101    /// <param name="disposing"><see langword="true"/> when called from <see cref="Dispose()"/>.</param>
 102    protected virtual void Dispose( bool disposing )
 103    {
 38104        if ( _disposed ) { return; }
 105
 106
 38107        if ( disposing )
 108        {
 38109            Browser?.Dispose();
 38110            _browserInitializeLock.Dispose();
 111        }
 112
 113
 38114        Browser   = null;
 38115        _disposed = true;
 38116    }
 117
 118    /// <summary>
 119    /// Starts the shared browser once: the configured executable, or Chromium downloaded to the download path (one down
 120    /// time across processes). A failed start leaves no browser, so the next call tries again.
 121    /// </summary>
 122    /// <param name="cancellationToken">Token to cancel waiting and downloading.</param>
 123    /// <returns>A task that completes when the browser is running.</returns>
 124    /// <exception cref="OperationCanceledException"><paramref name="cancellationToken"/> was cancelled.</exception>
 125    protected async Task InitializeAsync( CancellationToken cancellationToken = default )
 126    {
 36127        ObjectDisposedException.ThrowIf( _disposed, this );
 128
 36129        await _browserInitializeLock.WaitAsync( cancellationToken ).ConfigureAwait( false );
 130
 131        try
 132        {
 40133            if ( Browser is not null ) { return; }
 134
 135
 28136            var executablePath = _options.ExecutablePath ?? await DownloadAsync( cancellationToken ).ConfigureAwait( fal
 137
 28138            Browser = await Puppeteer.LaunchAsync( new LaunchOptions { Headless = true, ExecutablePath = executablePath 
 18139        }
 34140        finally { _browserInitializeLock.Release(); }
 24141    }
 142
 143
 144    private async Task<string> DownloadAsync( CancellationToken cancellationToken )
 145    {
 18146        Directory.CreateDirectory( _options.DownloadPath );
 147
 18148        var downloadLock = await AcquireDownloadLockAsync( Path.Combine( _options.DownloadPath, ".download.lock" ), canc
 149
 18150        await using ( downloadLock.ConfigureAwait( false ) )
 151        {
 18152            var installed = await new BrowserFetcher( new BrowserFetcherOptions { Path = _options.DownloadPath } ).Downl
 153
 154
 18155            return installed.GetExecutablePath();
 156        }
 18157    }
 158
 159    /// <summary>An exclusive lock file shared by every process using the same download path.</summary>
 160    private static async Task<FileStream> AcquireDownloadLockAsync( string lockPath, CancellationToken cancellationToken
 161    {
 162        while ( true )
 163        {
 77164            cancellationToken.ThrowIfCancellationRequested();
 165
 77166            try { return new FileStream( lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None ); }
 118167            catch ( IOException ) { await Task.Delay( _downloadLockRetry, cancellationToken ).ConfigureAwait( false ); }
 168        }
 18169    }
 170
 171    private async Task<LayerResponse<byte[]>> PrintPdfAsync( PrintOptions options, string? printUrl, string? htmlContent
 172    {
 36173        ArgumentNullException.ThrowIfNull( options );
 174
 60175        try { await InitializeAsync( cancellationToken ).ConfigureAwait( false ); }
 4176        catch ( OperationCanceledException ) { throw; }
 10177        catch ( Exception ex ) { return PrintPdfErrors.BrowserNotStarted( ex ); }
 178
 179
 24180        try { return await PrintAsync( options, printUrl, htmlContent, cancellationToken ).ConfigureAwait( false ); }
 0181        catch ( OperationCanceledException ) { throw; }
 0182        catch ( Exception ex ) { return PrintPdfErrors.PrintFailed( ex ); }
 34183    }
 184
 185    private async Task<byte[]> PrintAsync( PrintOptions options, string? printUrl, string? htmlContent, CancellationToke
 186    {
 24187        var page = await Browser!.NewPageAsync().ConfigureAwait( false );
 188
 48189        await using ( page.ConfigureAwait( false ) ) { return await PrintPageAsync( page, options, printUrl, htmlContent
 24190    }
 191
 192    private static async Task<byte[]> PrintPageAsync( IPage page, PrintOptions options, string? printUrl, string? htmlCo
 193    {
 24194        await page.EmulateMediaTypeAsync( MediaType.Print ).ConfigureAwait( false );
 24195        await page.SetViewportAsync( Viewport( options, options.Width, options.Height ) ).ConfigureAwait( false );
 24196        cancellationToken.ThrowIfCancellationRequested();
 197
 198
 24199        if ( !string.IsNullOrWhiteSpace( printUrl ) )
 200        {
 4201            await page.GoToAsync( printUrl, new NavigationOptions { WaitUntil = [ WaitUntilNavigation.DOMContentLoaded ]
 202        }
 20203        else if ( !string.IsNullOrWhiteSpace( htmlContent ) )
 204        {
 20205            await page.SetContentAsync( htmlContent, new SetContentOptions { WaitUntil = [ WaitUntilNavigation.DOMConten
 20206                      .ConfigureAwait( false );
 207        }
 208
 209
 24210        await page.EvaluateExpressionHandleAsync( "document.fonts.ready" ).ConfigureAwait( false );
 211
 24212        var contentSize = await page.EvaluateFunctionAsync<ViewPortOptions>(
 24213                                                                              "() => { return { width: document.document
 24214                                    .ConfigureAwait( false );
 24215        cancellationToken.ThrowIfCancellationRequested();
 216
 217
 218        // Fit the viewport to the content
 24219        await page.SetViewportAsync( Viewport( options, contentSize.Width, contentSize.Height ) ).ConfigureAwait( false 
 220
 221
 24222        return await page.PdfDataAsync( new PdfOptions
 24223                                            {
 24224                                                PrintBackground     = options.PrintBackground,
 24225                                                Height              = contentSize.Height,
 24226                                                Width               = contentSize.Width,
 24227                                                Format              = options.Format,
 24228                                                MarginOptions       = options.MarginOptions,
 24229                                                DisplayHeaderFooter = options.DisplayHeaderFooter,
 24230                                                FooterTemplate      = options.FooterTemplate,
 24231                                                Landscape           = options.Landscape || options.IsLandscape,
 24232                                                PageRanges          = options.PageRanges,
 24233                                                Scale               = options.Scale,
 24234                                                PreferCSSPageSize   = options.PreferCSSPageSize,
 24235                                                OmitBackground      = options.OmitBackground,
 24236                                                HeaderTemplate      = options.HeaderTemplate
 24237                                            } )
 24238                         .ConfigureAwait( false );
 24239    }
 240
 241    /// <summary>Absolute http(s) URL, and its host in <see cref="ChromiumOptions.AllowedHosts"/> when the list isn't em
 242    private bool IsAllowed( Uri url )
 18243        => url.IsAbsoluteUri
 18244           && ( url.Scheme == Uri.UriSchemeHttp || url.Scheme == Uri.UriSchemeHttps )
 18245           && ( _options.AllowedHosts.Count == 0 || _options.AllowedHosts.Contains( url.Host, StringComparer.OrdinalIgno
 246
 247    private static ViewPortOptions Viewport( PrintOptions options, int width, int height )
 48248        => new()
 48249               {
 48250                   Width             = width,
 48251                   Height            = height,
 48252                   IsMobile          = options.IsMobile,
 48253                   DeviceScaleFactor = options.DeviceScaleFactor,
 48254                   IsLandscape       = options.IsLandscape
 48255               };
 256}