| | | 1 | | namespace 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> |
| | | 8 | | public class ChromiumHandler : IAsyncDisposable, IDisposable |
| | | 9 | | { |
| | 2 | 10 | | private static readonly TimeSpan _downloadLockRetry = TimeSpan.FromMilliseconds( 250 ); |
| | | 11 | | |
| | 38 | 12 | | 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 |
| | 76 | 19 | | 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 |
| | 2 | 23 | | 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 | | { |
| | 56 | 33 | | if ( !_disposed && Browser is not null ) { await Browser.DisposeAsync().ConfigureAwait( false ); } |
| | | 34 | | |
| | | 35 | | |
| | 38 | 36 | | Browser = null; |
| | 38 | 37 | | Dispose( true ); |
| | 38 | 38 | | GC.SuppressFinalize( this ); |
| | 38 | 39 | | } |
| | | 40 | | |
| | | 41 | | /// <summary>Closes the browser.</summary> |
| | | 42 | | public void Dispose() |
| | | 43 | | { |
| | 0 | 44 | | Dispose( true ); |
| | 0 | 45 | | GC.SuppressFinalize( this ); |
| | 0 | 46 | | } |
| | | 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 | | { |
| | 0 | 57 | | ArgumentNullException.ThrowIfNull( view ); |
| | | 58 | | |
| | 0 | 59 | | var html = await view.GetHtmlAsStringAsync( context ).ConfigureAwait( false ); |
| | | 60 | | |
| | | 61 | | |
| | 0 | 62 | | return await PrintPdfAsync( options, null, html, cancellationToken ).ConfigureAwait( false ); |
| | 0 | 63 | | } |
| | | 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 |
| | 32 | 72 | | => 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 | | { |
| | 18 | 92 | | ArgumentNullException.ThrowIfNull( url ); |
| | | 93 | | |
| | 32 | 94 | | if ( !IsAllowed( url ) ) { return Task.FromResult<LayerResponse<byte[]>>( PrintPdfErrors.UrlNotAllowed( url ) ); |
| | | 95 | | |
| | | 96 | | |
| | 4 | 97 | | 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 | | { |
| | 38 | 104 | | if ( _disposed ) { return; } |
| | | 105 | | |
| | | 106 | | |
| | 38 | 107 | | if ( disposing ) |
| | | 108 | | { |
| | 38 | 109 | | Browser?.Dispose(); |
| | 38 | 110 | | _browserInitializeLock.Dispose(); |
| | | 111 | | } |
| | | 112 | | |
| | | 113 | | |
| | 38 | 114 | | Browser = null; |
| | 38 | 115 | | _disposed = true; |
| | 38 | 116 | | } |
| | | 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 | | { |
| | 36 | 127 | | ObjectDisposedException.ThrowIf( _disposed, this ); |
| | | 128 | | |
| | 36 | 129 | | await _browserInitializeLock.WaitAsync( cancellationToken ).ConfigureAwait( false ); |
| | | 130 | | |
| | | 131 | | try |
| | | 132 | | { |
| | 40 | 133 | | if ( Browser is not null ) { return; } |
| | | 134 | | |
| | | 135 | | |
| | 28 | 136 | | var executablePath = _options.ExecutablePath ?? await DownloadAsync( cancellationToken ).ConfigureAwait( fal |
| | | 137 | | |
| | 28 | 138 | | Browser = await Puppeteer.LaunchAsync( new LaunchOptions { Headless = true, ExecutablePath = executablePath |
| | 18 | 139 | | } |
| | 34 | 140 | | finally { _browserInitializeLock.Release(); } |
| | 24 | 141 | | } |
| | | 142 | | |
| | | 143 | | |
| | | 144 | | private async Task<string> DownloadAsync( CancellationToken cancellationToken ) |
| | | 145 | | { |
| | 18 | 146 | | Directory.CreateDirectory( _options.DownloadPath ); |
| | | 147 | | |
| | 18 | 148 | | var downloadLock = await AcquireDownloadLockAsync( Path.Combine( _options.DownloadPath, ".download.lock" ), canc |
| | | 149 | | |
| | 18 | 150 | | await using ( downloadLock.ConfigureAwait( false ) ) |
| | | 151 | | { |
| | 18 | 152 | | var installed = await new BrowserFetcher( new BrowserFetcherOptions { Path = _options.DownloadPath } ).Downl |
| | | 153 | | |
| | | 154 | | |
| | 18 | 155 | | return installed.GetExecutablePath(); |
| | | 156 | | } |
| | 18 | 157 | | } |
| | | 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 | | { |
| | 77 | 164 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 165 | | |
| | 77 | 166 | | try { return new FileStream( lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None ); } |
| | 118 | 167 | | catch ( IOException ) { await Task.Delay( _downloadLockRetry, cancellationToken ).ConfigureAwait( false ); } |
| | | 168 | | } |
| | 18 | 169 | | } |
| | | 170 | | |
| | | 171 | | private async Task<LayerResponse<byte[]>> PrintPdfAsync( PrintOptions options, string? printUrl, string? htmlContent |
| | | 172 | | { |
| | 36 | 173 | | ArgumentNullException.ThrowIfNull( options ); |
| | | 174 | | |
| | 60 | 175 | | try { await InitializeAsync( cancellationToken ).ConfigureAwait( false ); } |
| | 4 | 176 | | catch ( OperationCanceledException ) { throw; } |
| | 10 | 177 | | catch ( Exception ex ) { return PrintPdfErrors.BrowserNotStarted( ex ); } |
| | | 178 | | |
| | | 179 | | |
| | 24 | 180 | | try { return await PrintAsync( options, printUrl, htmlContent, cancellationToken ).ConfigureAwait( false ); } |
| | 0 | 181 | | catch ( OperationCanceledException ) { throw; } |
| | 0 | 182 | | catch ( Exception ex ) { return PrintPdfErrors.PrintFailed( ex ); } |
| | 34 | 183 | | } |
| | | 184 | | |
| | | 185 | | private async Task<byte[]> PrintAsync( PrintOptions options, string? printUrl, string? htmlContent, CancellationToke |
| | | 186 | | { |
| | 24 | 187 | | var page = await Browser!.NewPageAsync().ConfigureAwait( false ); |
| | | 188 | | |
| | 48 | 189 | | await using ( page.ConfigureAwait( false ) ) { return await PrintPageAsync( page, options, printUrl, htmlContent |
| | 24 | 190 | | } |
| | | 191 | | |
| | | 192 | | private static async Task<byte[]> PrintPageAsync( IPage page, PrintOptions options, string? printUrl, string? htmlCo |
| | | 193 | | { |
| | 24 | 194 | | await page.EmulateMediaTypeAsync( MediaType.Print ).ConfigureAwait( false ); |
| | 24 | 195 | | await page.SetViewportAsync( Viewport( options, options.Width, options.Height ) ).ConfigureAwait( false ); |
| | 24 | 196 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 197 | | |
| | | 198 | | |
| | 24 | 199 | | if ( !string.IsNullOrWhiteSpace( printUrl ) ) |
| | | 200 | | { |
| | 4 | 201 | | await page.GoToAsync( printUrl, new NavigationOptions { WaitUntil = [ WaitUntilNavigation.DOMContentLoaded ] |
| | | 202 | | } |
| | 20 | 203 | | else if ( !string.IsNullOrWhiteSpace( htmlContent ) ) |
| | | 204 | | { |
| | 20 | 205 | | await page.SetContentAsync( htmlContent, new SetContentOptions { WaitUntil = [ WaitUntilNavigation.DOMConten |
| | 20 | 206 | | .ConfigureAwait( false ); |
| | | 207 | | } |
| | | 208 | | |
| | | 209 | | |
| | 24 | 210 | | await page.EvaluateExpressionHandleAsync( "document.fonts.ready" ).ConfigureAwait( false ); |
| | | 211 | | |
| | 24 | 212 | | var contentSize = await page.EvaluateFunctionAsync<ViewPortOptions>( |
| | 24 | 213 | | "() => { return { width: document.document |
| | 24 | 214 | | .ConfigureAwait( false ); |
| | 24 | 215 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 216 | | |
| | | 217 | | |
| | | 218 | | // Fit the viewport to the content |
| | 24 | 219 | | await page.SetViewportAsync( Viewport( options, contentSize.Width, contentSize.Height ) ).ConfigureAwait( false |
| | | 220 | | |
| | | 221 | | |
| | 24 | 222 | | return await page.PdfDataAsync( new PdfOptions |
| | 24 | 223 | | { |
| | 24 | 224 | | PrintBackground = options.PrintBackground, |
| | 24 | 225 | | Height = contentSize.Height, |
| | 24 | 226 | | Width = contentSize.Width, |
| | 24 | 227 | | Format = options.Format, |
| | 24 | 228 | | MarginOptions = options.MarginOptions, |
| | 24 | 229 | | DisplayHeaderFooter = options.DisplayHeaderFooter, |
| | 24 | 230 | | FooterTemplate = options.FooterTemplate, |
| | 24 | 231 | | Landscape = options.Landscape || options.IsLandscape, |
| | 24 | 232 | | PageRanges = options.PageRanges, |
| | 24 | 233 | | Scale = options.Scale, |
| | 24 | 234 | | PreferCSSPageSize = options.PreferCSSPageSize, |
| | 24 | 235 | | OmitBackground = options.OmitBackground, |
| | 24 | 236 | | HeaderTemplate = options.HeaderTemplate |
| | 24 | 237 | | } ) |
| | 24 | 238 | | .ConfigureAwait( false ); |
| | 24 | 239 | | } |
| | | 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 ) |
| | 18 | 243 | | => url.IsAbsoluteUri |
| | 18 | 244 | | && ( url.Scheme == Uri.UriSchemeHttp || url.Scheme == Uri.UriSchemeHttps ) |
| | 18 | 245 | | && ( _options.AllowedHosts.Count == 0 || _options.AllowedHosts.Contains( url.Host, StringComparer.OrdinalIgno |
| | | 246 | | |
| | | 247 | | private static ViewPortOptions Viewport( PrintOptions options, int width, int height ) |
| | 48 | 248 | | => new() |
| | 48 | 249 | | { |
| | 48 | 250 | | Width = width, |
| | 48 | 251 | | Height = height, |
| | 48 | 252 | | IsMobile = options.IsMobile, |
| | 48 | 253 | | DeviceScaleFactor = options.DeviceScaleFactor, |
| | 48 | 254 | | IsLandscape = options.IsLandscape |
| | 48 | 255 | | }; |
| | | 256 | | } |