From 7a114f47a64f1cf03165991d2715d0d0395a93fe Mon Sep 17 00:00:00 2001 From: TekH Date: Tue, 29 Sep 2026 19:21:51 +0200 Subject: [PATCH] feat(sender-editor): add PDF page rendering pipeline --- .../Common/Dto/PdfPageImageDto.cs | 16 ++++++ .../Common/Dto/PdfRenderRequestDto.cs | 6 ++ .../Interfaces/Doc/IPdfPageImageRenderer.cs | 8 +++ ...nvelopeGenerator.Infrastructure.Doc.csproj | 17 ++++++ .../PdfPageImageRenderer.cs | 57 +++++++++++++++++++ .../Program.cs | 1 + .../Services/PdfRenderService.cs | 32 +++++++++++ .../Controllers/PdfRenderController.cs | 24 ++++++++ .../EnvelopeGenerator.Server.csproj | 1 + .../EnvelopeGenerator.Server/Program.cs | 4 ++ EnvelopeGenerator.sln | 7 +++ 11 files changed, 173 insertions(+) create mode 100644 EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs create mode 100644 EnvelopeGenerator.Application/Common/Dto/PdfRenderRequestDto.cs create mode 100644 EnvelopeGenerator.Application/Common/Interfaces/Doc/IPdfPageImageRenderer.cs create mode 100644 EnvelopeGenerator.Infrastructure.Doc/EnvelopeGenerator.Infrastructure.Doc.csproj create mode 100644 EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs create mode 100644 EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/PdfRenderService.cs create mode 100644 EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfRenderController.cs diff --git a/EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs b/EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs new file mode 100644 index 00000000..7485d129 --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs @@ -0,0 +1,16 @@ +namespace EnvelopeGenerator.Application.Common.Dto; + +public sealed class PdfPageImageDto +{ + public int PageNumber { get; init; } + + public double WidthPt { get; init; } + + public double HeightPt { get; init; } + + public int WidthPx { get; init; } + + public int HeightPx { get; init; } + + public string ImageDataUrl { get; init; } = string.Empty; +} diff --git a/EnvelopeGenerator.Application/Common/Dto/PdfRenderRequestDto.cs b/EnvelopeGenerator.Application/Common/Dto/PdfRenderRequestDto.cs new file mode 100644 index 00000000..b4554c37 --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Dto/PdfRenderRequestDto.cs @@ -0,0 +1,6 @@ +namespace EnvelopeGenerator.Application.Common.Dto; + +public sealed class PdfRenderRequestDto +{ + public byte[]? PdfBytes { get; init; } +} diff --git a/EnvelopeGenerator.Application/Common/Interfaces/Doc/IPdfPageImageRenderer.cs b/EnvelopeGenerator.Application/Common/Interfaces/Doc/IPdfPageImageRenderer.cs new file mode 100644 index 00000000..361211f0 --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Interfaces/Doc/IPdfPageImageRenderer.cs @@ -0,0 +1,8 @@ +using EnvelopeGenerator.Application.Common.Dto; + +namespace EnvelopeGenerator.Application.Common.Interfaces.Doc; + +public interface IPdfPageImageRenderer +{ + Task> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default); +} diff --git a/EnvelopeGenerator.Infrastructure.Doc/EnvelopeGenerator.Infrastructure.Doc.csproj b/EnvelopeGenerator.Infrastructure.Doc/EnvelopeGenerator.Infrastructure.Doc.csproj new file mode 100644 index 00000000..c2ba7cf3 --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Doc/EnvelopeGenerator.Infrastructure.Doc.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs b/EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs new file mode 100644 index 00000000..234397d8 --- /dev/null +++ b/EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs @@ -0,0 +1,57 @@ +using DevExpress.Drawing; +using DevExpress.Pdf; +using EnvelopeGenerator.Application.Common.Dto; +using EnvelopeGenerator.Application.Common.Interfaces.Doc; + +namespace EnvelopeGenerator.Infrastructure.Doc; + +public class PdfPageImageRenderer : IPdfPageImageRenderer +{ + private const float RenderDpi = 110f; + + public Task> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pdfBytes); + + if (pdfBytes.Length == 0) + return Task.FromResult>(Array.Empty()); + + using var stream = new MemoryStream(pdfBytes, writable: false); + using var processor = new PdfDocumentProcessor(); + processor.LoadDocument(stream); + + var pages = processor.Document.Pages; + if (pages.Count == 0) + return Task.FromResult>(Array.Empty()); + + var renderOptions = PdfPageRenderingParameters.CreateWithResolution(RenderDpi); + var results = new List(pages.Count); + + for (var i = 0; i < pages.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var pageNumber = i + 1; + var page = pages[i]; + var cropBox = page.CropBox; + + using DXBitmap bitmap = processor.CreateDXBitmap(pageNumber, renderOptions); + using var imageStream = new MemoryStream(); + bitmap.Save(imageStream, DXImageFormat.Png); + + var dataUrl = $"data:image/png;base64,{Convert.ToBase64String(imageStream.ToArray())}"; + + results.Add(new PdfPageImageDto + { + PageNumber = pageNumber, + WidthPt = cropBox.Width, + HeightPt = cropBox.Height, + WidthPx = bitmap.Width, + HeightPx = bitmap.Height, + ImageDataUrl = dataUrl + }); + } + + return Task.FromResult>(results); + } +} diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Program.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Program.cs index e9d390ed..0600c35f 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Program.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Program.cs @@ -43,6 +43,7 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // UI Preferences & Theme builder.Services.AddScoped(); diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/PdfRenderService.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/PdfRenderService.cs new file mode 100644 index 00000000..3da25292 --- /dev/null +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/PdfRenderService.cs @@ -0,0 +1,32 @@ +using System.Net.Http.Json; +using System.Text.Json; +using EnvelopeGenerator.Application.Common.Dto; + +namespace EnvelopeGenerator.Server.Client.Services; + +public class PdfRenderService(IHttpClientFactory httpClientFactory) +{ + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web); + + public async Task> RenderPagesAsync(byte[] pdfBytes, CancellationToken cancel = default) + { + using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server"); + using var response = await http.PostAsJsonAsync( + "/api/PdfRender/pages", + new PdfRenderRequestDto { PdfBytes = pdfBytes }, + _jsonOptions, + cancel); + + if (!response.IsSuccessStatusCode) + { + var statusCode = (int)response.StatusCode; + var reasonPhrase = response.ReasonPhrase ?? "Unknown error"; + throw new HttpRequestException( + $"Failed to render PDF pages. Status: {statusCode} ({reasonPhrase})", + null, + response.StatusCode); + } + + return await response.Content.ReadFromJsonAsync>(_jsonOptions, cancel) ?? []; + } +} diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfRenderController.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfRenderController.cs new file mode 100644 index 00000000..3639c536 --- /dev/null +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfRenderController.cs @@ -0,0 +1,24 @@ +using DigitalData.Auth.Claims; +using EnvelopeGenerator.Application.Common.Dto; +using EnvelopeGenerator.Application.Common.Interfaces.Doc; +using EnvelopeGenerator.Domain.Constants; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace EnvelopeGenerator.Server.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class PdfRenderController(IPdfPageImageRenderer renderer) : ControllerBase +{ + [HttpPost("pages")] + [Authorize(AuthenticationSchemes = AuthScheme.Sender)] + public async Task RenderPages([FromBody] PdfRenderRequestDto? request, CancellationToken cancel) + { + if (request?.PdfBytes is not { Length: > 0 } bytes) + return BadRequest("Missing PDF bytes."); + + IReadOnlyList pages = await renderer.RenderPagesAsImagesAsync(bytes, cancel); + return Ok(pages); + } +} diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/EnvelopeGenerator.Server.csproj b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/EnvelopeGenerator.Server.csproj index fabf8493..ea79f405 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/EnvelopeGenerator.Server.csproj +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/EnvelopeGenerator.Server.csproj @@ -21,6 +21,7 @@ + diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Program.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Program.cs index e1114efe..804b55bb 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Program.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Program.cs @@ -26,6 +26,8 @@ using NLog; using DigitalData.Auth.Claims; using EnvelopeGenerator.Server; using DigitalData.EmailProfilerDispatcher; +using EnvelopeGenerator.Application.Common.Interfaces.Doc; +using EnvelopeGenerator.Infrastructure.Doc; var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger(); logger.Info("EnvelopeGenerator.Server logging initialized!"); @@ -342,6 +344,7 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); // Business Services (Server specific) builder.Services.AddScoped(); @@ -350,6 +353,7 @@ try builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/EnvelopeGenerator.sln b/EnvelopeGenerator.sln index cca97ec9..a8be861b 100644 --- a/EnvelopeGenerator.sln +++ b/EnvelopeGenerator.sln @@ -51,6 +51,8 @@ Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "EnvelopeGenerator.Service_l EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Service", "EnvelopeGenerator.Service\EnvelopeGenerator.Service.csproj", "{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Infrastructure.Doc", "EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj", "{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -113,6 +115,10 @@ Global {6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Debug|Any CPU.Build.0 = Debug|Any CPU {6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.ActiveCfg = Release|Any CPU {6B87FB5F-F4EA-45BE-A218-385DC2CE8E80}.Release|Any CPU.Build.0 = Release|Any CPU + {3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -136,6 +142,7 @@ Global {9C41A0B1-6AFE-AFC5-CE33-08052F45DAEF} = {BF1700D5-592E-4FFA-84E8-5480E289A1F0} {83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB} {6B87FB5F-F4EA-45BE-A218-385DC2CE8E80} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB} + {3B6EFE8D-F1EE-4666-87A0-9F90149CC61A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}