Skip to content

Commit

Permalink
Refactor HtmlToPdfConverter and update ReportSheetCache (#192)
Browse files Browse the repository at this point in the history
* Refactor HtmlToPdfConverter and update ReportSheetCache

* Move HtmlToPdfConverter to TournamentManager namespace
* Add detailed logging and error handling in HtmlToPdfConverter
* Add BrowserKind to set the kind of browser to use
* Add ability to create PDF from html content or a file
* Introduce DisplayMatchDate flag in ReportSheetCache
* Modify IsOutdated to check DisplayMatchDate flag
* Update Match constructor to include IServiceProvider
* Update ReportSheet action to use _serviceProvider for ReportSheetCache
* Updated ReportSheet.cshtml to conditionally display match date
* Add html {-webkit-print-color-adjust: exact; } CSS to ReportSheet.cshtml (enables colored output for PDF)
* Moved PuppeteerSharp package reference to TournamentManager.csproj
* Added localization for English and German
  • Loading branch information
axunonb authored Sep 29, 2024
1 parent 7557457 commit 149995e
Show file tree
Hide file tree
Showing 7 changed files with 119 additions and 28 deletions.
19 changes: 16 additions & 3 deletions League/Caching/ReportSheetCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public class ReportSheetCache
private readonly string _pathToBrowser;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger<ReportSheetCache> _logger;
// Can eventually be removed when the date is never displayed on report sheets
public static readonly bool DisplayMatchDate = false;

/// <summary>
/// Folder name for the report sheet cache
Expand Down Expand Up @@ -55,6 +57,11 @@ public ReportSheetCache(ITenantContext tenantContext, IConfiguration configurati
/// </summary>
public bool UsePuppeteer { get; set; }

/// <summary>
/// The kind of browser to use for generating the PDF.
/// </summary>
public TournamentManager.HtmlToPdfConverter.BrowserKind BrowserKind { get; set; }

private void EnsureCacheFolder()
{
var cacheFolder = Path.Combine(_webHostEnvironment.WebRootPath, ReportSheetCacheFolder);
Expand All @@ -81,8 +88,8 @@ public async Task<Stream> GetOrCreatePdf(MatchReportSheetRow data, string html,
{
_logger.LogDebug("Create new match report for tenant '{Tenant}', match '{MatchId}'", _tenantContext.Identifier, data.Id);

using var converter = new HtmlToPdfConverter(_pathToBrowser, CreateTempPathFolder(), _loggerFactory)
{ UsePuppeteer = UsePuppeteer };
using var converter = new TournamentManager.HtmlToPdfConverter.HtmlToPdfConverter(_pathToBrowser, CreateTempPathFolder(), _loggerFactory)
{ UsePuppeteer = UsePuppeteer, BrowserKind = BrowserKind};

var pdfData = await converter.GeneratePdfData(html, cancellationToken);

Expand All @@ -104,7 +111,13 @@ public async Task<Stream> GetOrCreatePdf(MatchReportSheetRow data, string html,
private static bool IsOutdated(string cacheFile, DateTime dataModifiedOn)
{
var fi = new FileInfo(cacheFile);
return !fi.Exists || fi.LastWriteTimeUtc < dataModifiedOn; // Database dates are in UTC

if (DisplayMatchDate) // Can eventually be removed when the date is never displayed on report sheets
{
return !fi.Exists || fi.LastWriteTimeUtc < dataModifiedOn; // Database dates are in UTC
}

return !fi.Exists;
}

private string GetPathToCacheFile(long matchId)
Expand Down
20 changes: 14 additions & 6 deletions League/Controllers/Match.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using TournamentManager.DAL.HelperClasses;
using TournamentManager.DAL.TypedViewClasses;
using TournamentManager.ExtensionMethods;
using TournamentManager.HtmlToPdfConverter;
using TournamentManager.ModelValidators;
using TournamentManager.MultiTenancy;

Expand All @@ -30,6 +31,7 @@ public class Match : AbstractController
private readonly IStringLocalizer<Match> _localizer;
private readonly IAuthorizationService _authorizationService;
private readonly Axuno.Tools.DateAndTime.TimeZoneConverter _timeZoneConverter;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<Match> _logger;
private readonly Axuno.BackgroundTask.IBackgroundQueue _queue;
private readonly SendEmailTask _sendMailTask;
Expand All @@ -52,6 +54,7 @@ public Match(ITenantContext tenantContext, IStringLocalizer<Match> localizer,
_appDb = tenantContext.DbContext.AppDb;
_localizer = localizer;
_authorizationService = authorizationService;
_serviceProvider = serviceProvider;
_logger = logger;

// Get required services from the service provider to stay below the 7 parameter limit of SonarCloud
Expand Down Expand Up @@ -706,17 +709,17 @@ private async Task<EnterResultViewModel> GetEnterResultViewModel(MatchEntity mat
/// if the match has not already been played.
/// </summary>
/// <param name="id"></param>
/// <param name="services"></param>
/// <param name="cancellationToken"></param>
/// <returns>A match report sheet suitable for a printout, if the match has not already been played.</returns>
[HttpGet("[action]/{id:long}")]
public async Task<IActionResult> ReportSheet(long id, IServiceProvider services, CancellationToken cancellationToken)
public async Task<IActionResult> ReportSheet(long id, CancellationToken cancellationToken)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
var cache = services.GetRequiredService<ReportSheetCache>();


MatchReportSheetRow? model = null;
var cache = _serviceProvider.GetRequiredService<ReportSheetCache>();
cache.UsePuppeteer = false;
cache.BrowserKind = BrowserKind.Chromium;

try
{
Expand All @@ -733,8 +736,12 @@ public async Task<IActionResult> ReportSheet(long id, IServiceProvider services,
$"~/Views/{nameof(Match)}/{ViewNames.Match.ReportSheet}.cshtml", model);

var stream = await cache.GetOrCreatePdf(model, html, cancellationToken);
_logger.LogInformation("PDF file returned for tenant '{Tenant}' and match id '{MatchId}'", _tenantContext.Identifier, id);
return new FileStreamResult(stream, "application/pdf");

if (stream != Stream.Null) // Returning Stream.Null would create an empty page in the web browser
{
_logger.LogInformation("PDF file returned for tenant '{Tenant}' and match id '{MatchId}'", _tenantContext.Identifier, id);
return new FileStreamResult(stream, "application/pdf");
}
}
catch (Exception e)
{
Expand All @@ -743,6 +750,7 @@ public async Task<IActionResult> ReportSheet(long id, IServiceProvider services,

// Not able to render report sheet as PDF: return HTML
Response.Clear();
_logger.LogError("HTML content instead of PDF returned for tenant '{Tenant}' and match id '{MatchId}'", _tenantContext.Identifier, id);
return View(ViewNames.Match.ReportSheet, model);
}

Expand Down
1 change: 0 additions & 1 deletion League/League.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ Localizations for English and German are included. The library is in operation o
<PackEmbeddedResource>true</PackEmbeddedResource>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="PuppeteerSharp" Version="20.0.2" />
<PackageReference Include="StackifyMiddleware" Version="3.3.3.4767" />
<PackageReference Include="System.Data.SqlClient" Version="4.8.6" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
Expand Down
17 changes: 13 additions & 4 deletions League/Views/Match/ReportSheet.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
@inject Microsoft.AspNetCore.Mvc.Localization.IViewLocalizer localizer
@model TournamentManager.DAL.TypedViewClasses.MatchReportSheetRow
@{
// Can eventually be removed when the date is never displayed on report sheets
var displayMatchDate = League.Caching.ReportSheetCache.DisplayMatchDate;
Layout = null;
var numberOfSets = Model.BestOf ? Model.NumOfSets * 2 - 1 : Model.NumOfSets;

Expand Down Expand Up @@ -123,6 +125,9 @@
</style>
<!-- Custom styles for League ReportSheet -->
<style>
html {
-webkit-print-color-adjust: exact; /* Show colors in PDF output */
}
@@media print {
@@page {
size: 210mm 297mm;
Expand Down Expand Up @@ -247,14 +252,18 @@
</div>
<div class="col-6" style="padding: 0">
<div class="text-end">
@(Model.OrigPlannedStart.HasValue ? $"{localizer["Changed date"].Value}:" : $"{localizer["Date"].Value}:")
@if (Model.PlannedStart.HasValue && TimeZoneConverter != null)
@if (displayMatchDate)
{
@($"{TimeZoneConverter.ToZonedTime(Model.PlannedStart)!.DateTimeOffset.DateTime:D} - {TimeZoneConverter.ToZonedTime(Model.PlannedStart)!.DateTimeOffset.DateTime:t}")
@(Model.OrigPlannedStart.HasValue ? $"{localizer["Changed date"].Value}:" : $"{localizer["Date"].Value}:")
@if (Model.PlannedStart.HasValue)
{
@($"{TimeZoneConverter.ToZonedTime(Model.PlannedStart)!.DateTimeOffset.DateTime:D} - {TimeZoneConverter.ToZonedTime(Model.PlannedStart)!.DateTimeOffset.DateTime:t}")
}
}
else
{
<text>___________</text>
@localizer["Date"].Value
<text>: ____________________</text>
}
</div>
<div class="text-end">@localizer["Start time"]: ___________</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// Copyright Volleyball League Project maintainers and contributors.
// Licensed under the MIT license.
//

namespace TournamentManager.HtmlToPdfConverter;

/// <summary>
/// Specifies to kind of browser to use in <see cref="HtmlToPdfConverter"/>.
/// </summary>
public enum BrowserKind
{
/// <summary>Chrome.</summary>
Chrome,
/// <summary>Firefox.</summary>
Firefox,
/// <summary>Chromium.</summary>
Chromium,
/// <summary>Chrome headless shell.</summary>
ChromeHeadlessShell
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
// Licensed under the MIT license.
//

namespace League.Caching;
using Microsoft.Extensions.Logging;

namespace TournamentManager.HtmlToPdfConverter;

#pragma warning disable CA3003 // reason: False positive due to CancellationToken in GetPdfDataBrowser

Expand Down Expand Up @@ -33,6 +35,7 @@ public HtmlToPdfConverter(string pathToBrowser, string tempPath, ILoggerFactory
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<HtmlToPdfConverter>();
UsePuppeteer = false;
BrowserKind = BrowserKind.Chromium;
}

/// <summary>
Expand All @@ -41,6 +44,11 @@ public HtmlToPdfConverter(string pathToBrowser, string tempPath, ILoggerFactory
/// </summary>
public bool UsePuppeteer { get; set; }

/// <summary>
/// The kind of browser to use for generating the PDF.
/// </summary>
public BrowserKind BrowserKind { get; set; }

private void EnsureTempFolder(string tempFolder)
{
if (Directory.Exists(tempFolder)) return;
Expand All @@ -58,19 +66,38 @@ private void EnsureTempFolder(string tempFolder)
public async Task<byte[]?> GeneratePdfData(string html, CancellationToken cancellationToken)
{
var pdfData = UsePuppeteer
? await GetPdfDataPuppeteer(html)
? await GetPdfDataPuppeteer(html, false)
: await GetPdfDataBrowser(html, cancellationToken);

return pdfData;
}

/// <summary>
/// Creates a PDF file from the specified HTML file.
/// </summary>
/// <param name="htmlFile"></param>
/// <param name="cancellationToken"></param>
/// <returns>A <see cref="Stream"/> of the PDF file.</returns>
public async Task<byte[]?> GeneratePdfData(FileInfo htmlFile, CancellationToken cancellationToken)
{
var pdfData = UsePuppeteer
? await GetPdfDataPuppeteer(htmlFile)
: await GetPdfDataBrowser(htmlFile, cancellationToken);

return pdfData;
}

private async Task<byte[]?> GetPdfDataBrowser(string html, CancellationToken cancellationToken)
{
var tmpHtmlPath = await CreateHtmlFile(html, cancellationToken);
return await GetPdfDataBrowser(new FileInfo(tmpHtmlPath), cancellationToken);
}

private async Task<byte[]?> GetPdfDataBrowser(FileInfo fileInfo, CancellationToken cancellationToken)
{
try
{
var tmpPdfFile = await CreatePdfDataBrowser(tmpHtmlPath, cancellationToken);
var tmpPdfFile = await CreatePdfDataBrowser(fileInfo.FullName, cancellationToken);

if (tmpPdfFile != null && File.Exists(tmpPdfFile))
return await File.ReadAllBytesAsync(tmpPdfFile, cancellationToken);
Expand All @@ -85,34 +112,45 @@ private void EnsureTempFolder(string tempFolder)
}
}

private async Task<byte[]?> GetPdfDataPuppeteer(string html)
private async Task<byte[]?> GetPdfDataPuppeteer(FileInfo fileInfo)
{
return await GetPdfDataPuppeteer(fileInfo.FullName, true);
}

private async Task<byte[]?> GetPdfDataPuppeteer(string fileOrHtmlContent, bool isFile)
{
var options = new PuppeteerSharp.LaunchOptions
{
Headless = true,
Browser = PuppeteerSharp.SupportedBrowser.Chromium,
Browser = (PuppeteerSharp.SupportedBrowser) BrowserKind,
// Alternative: --use-cmd-decoder=validating
Args = new[] // Chromium-based browsers require using a sandboxed browser for PDF generation, unless sandbox is disabled
{ "--no-sandbox", "--disable-gpu", "--disable-extensions", "--use-cmd-decoder=passthrough" },
{ "--no-sandbox", "--disable-gpu", "--allow-file-access-from-files", "--disable-extensions", "--use-cmd-decoder=passthrough" },
ExecutablePath = _pathToBrowser,
UserDataDir = _tempFolder,
Timeout = 5000,
ProtocolTimeout = 10000 // default is 180,000 - used for page.PdfDataAsync
};

// Use Puppeteer as a wrapper for the browser, which can generate PDF from HTML
// Start command line arguments set by Puppeteer v20:
// --allow-pre-commit-input --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-field-trial-config --disable-hang-monitor --disable-infobars --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-search-engine-choice-screen --disable-sync --enable-automation --enable-blink-features=IdleDetection --export-tagged-pdf --generate-pdf-document-outline --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --disable-features=Translate,AcceptCHFrame,MediaRouter,OptimizationHints,ProcessPerSiteUpToMainFrameThreshold --enable-features= --headless=new --hide-scrollbars --mute-audio about:blank --no-sandbox --disable-gpu --disable-extensions --use-cmd-decoder=passthrough --remote-debugging-port=0 --user-data-dir="C:\Users\xyz\AppData\Local\Temp\yk1fjkgt.phb"
await using var browser = await PuppeteerSharp.Puppeteer.LaunchAsync(options, _loggerFactory).ConfigureAwait(false);
await using var page = await browser.NewPageAsync().ConfigureAwait(false);

await page.SetContentAsync(html); // Bootstrap 5 is loaded from CDN
if (isFile)
await page.GoToAsync(new Uri(fileOrHtmlContent).AbsoluteUri);
else
await page.SetContentAsync(fileOrHtmlContent);

await page.EvaluateExpressionHandleAsync("document.fonts.ready"); // Wait for fonts to be loaded. Omitting this might result in no text rendered in pdf.

try
{
return await page.PdfDataAsync(new PuppeteerSharp.PdfOptions
{ Scale = 1.0M, Format = PuppeteerSharp.Media.PaperFormat.A4 }).ConfigureAwait(false);
{ Scale = 1.0M, Format = PuppeteerSharp.Media.PaperFormat.A4 }).ConfigureAwait(false);
}
catch(Exception ex)
catch (Exception ex)
{
_logger.LogError(ex, "Error creating PDF file with Puppeteer");
return null;
Expand All @@ -125,21 +163,23 @@ private void EnsureTempFolder(string tempFolder)
// Note: non-existing file is handled in MovePdfToCache
var pdfFile = Path.Combine(_tempFolder, Path.GetRandomFileName() + ".pdf");

// Note: --timeout ={timeout.TotalMilliseconds} as Browser argument does not work
var timeout = TimeSpan.FromMilliseconds(5000);

// Run the Browser
// Command line switches overview: https://kapeli.com/cheat_sheets/Chromium_Command_Line_Switches.docset/Contents/Resources/Documents/index
// or better https://peter.sh/experiments/chromium-command-line-switches/
var startInfo = new System.Diagnostics.ProcessStartInfo(_pathToBrowser,
$"--allow-pre-commit-input --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-sync --enable-automation --enable-blink-features=IdleDetection --enable-features=NetworkServiceInProcess2 --export-tagged-pdf --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --headless --hide-scrollbars --mute-audio --no-sandbox --disable-gpu --use-cmd-decoder=passthrough --no-margins --user-data-dir={_tempFolder} --no-pdf-header-footer --print-to-pdf={pdfFile} {htmlFile}")
{ CreateNoWindow = true, UseShellExecute = false };
var proc = System.Diagnostics.Process.Start(startInfo);
$"--allow-pre-commit-input --allow-file-access-from-files --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-sync --enable-automation --enable-blink-features=IdleDetection --enable-features=NetworkServiceInProcess2 --export-tagged-pdf --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --headless --hide-scrollbars --mute-audio --no-sandbox --disable-gpu --use-cmd-decoder=passthrough --no-margins --no-pdf-header-footer --print-to-pdf={pdfFile} {htmlFile}")
{ CreateNoWindow = true, UseShellExecute = false };
using var proc = System.Diagnostics.Process.Start(startInfo);

if (proc == null)
{
_logger.LogError("Process '{PathToBrowser}' could not be started.", _pathToBrowser);
return pdfFile;
}

var timeout = TimeSpan.FromMilliseconds(5000);
var processTask = proc.WaitForExitAsync(cancellationToken);

await Task.WhenAny(processTask, Task.Delay(timeout, cancellationToken));
Expand All @@ -154,7 +194,7 @@ private async Task<string> CreateHtmlFile(string html, CancellationToken cancell
{
var htmlFile = Path.Combine(_tempFolder, Path.GetRandomFileName() + ".html"); // extension must be "html"
await File.WriteAllTextAsync(htmlFile, html, cancellationToken);
return new Uri(htmlFile).AbsoluteUri;
return htmlFile;
}

private static string CreateTempPathFolder(string tempPath)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Volleyball League is an open source sports platform that brings everything neces
</PackageReference>
<PackageReference Include="OxyPlot.Core" Version="2.1.2" />
<PackageReference Include="OxyPlot.SkiaSharp" Version="2.1.2" />
<PackageReference Include="PuppeteerSharp" Version="20.0.2" />
<PackageReference Include="System.Reflection.TypeExtensions" Version="4.7.0" />
<PackageReference Include="Ical.Net" Version="4.2.0" />
<PackageReference Include="libphonenumber-csharp" Version="8.13.39" />
Expand Down

0 comments on commit 149995e

Please sign in to comment.