Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor HtmlToPdfConverter and update ReportSheetCache #192

Merged
merged 2 commits into from
Sep 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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