Compare commits
47 Commits
251ecc34d9
...
2804993ea6
| Author | SHA1 | Date | |
|---|---|---|---|
| 2804993ea6 | |||
| 06a9dc7385 | |||
| 4b5c763f24 | |||
| 559c726118 | |||
| 1106a86ec3 | |||
| 89436406ce | |||
| c96cbbc8d3 | |||
| 9b85e55cd4 | |||
| 536413bafe | |||
| b37ccc8538 | |||
| d0606f3605 | |||
| c037ad8446 | |||
| e2bec710e0 | |||
| 61b11fc216 | |||
| c4ec0c2b48 | |||
| eed9d46e19 | |||
| c1bb3abeef | |||
| d72d41ec2d | |||
| d4107f6f89 | |||
| 22ac2889af | |||
| a23c78ec3a | |||
| 41f97ce533 | |||
| 25fbea205f | |||
| e95f070b9b | |||
| aafe46a738 | |||
| 0bec759396 | |||
| 5c3fafff1b | |||
| bc273c7f4f | |||
| e12b64a517 | |||
| 3598c5f9c6 | |||
| 522de8a863 | |||
| cb552e54e7 | |||
| fa4e55242d | |||
| e14044c48a | |||
| 34e38f19e5 | |||
| 61b1595258 | |||
| 26458a4017 | |||
| 2c673ea98e | |||
| 1989ca7ef7 | |||
| 0f4d860176 | |||
| 645dfceafa | |||
| 07be9b9f02 | |||
| a1e8575018 | |||
| b4befde418 | |||
| 1af158840e | |||
| 5dc2e38507 | |||
| c93488c29f |
36
AGENTS.md
36
AGENTS.md
@@ -188,7 +188,7 @@ After completing all Phase 1-3 controllers (PdfValidation, PdfAttachment, SwissQ
|
||||
dotnet build
|
||||
```
|
||||
|
||||
**Run tests (30 tests as of Feature 3 - PDF/A Validation):**
|
||||
**Run tests (101 passed, 7 skipped as of Feature 7 - PDF Stamp):**
|
||||
```powershell
|
||||
dotnet test
|
||||
```
|
||||
@@ -197,7 +197,8 @@ dotnet test
|
||||
```powershell
|
||||
dotnet run --project DocumentOperator.API
|
||||
```
|
||||
Swagger UI: `https://localhost:<port>/swagger`
|
||||
Swagger UI: `https://localhost:7186/swagger`
|
||||
Serilog UI: `https://localhost:7186/serilog-ui` (Web-based log viewer)
|
||||
|
||||
**Target framework:** .NET 8.0
|
||||
**SDK required:** 8.0.412 or later (repo has 8.0.412–10.0.203 available)
|
||||
@@ -214,6 +215,8 @@ Swagger UI: `https://localhost:<port>/swagger`
|
||||
| **MediatR** (14.1.0) | CQRS: 1 handler per feature | Application layer |
|
||||
| **FluentValidation** (12.1.1) | Request validation (runs via ValidationBehavior before handlers) | Application layer |
|
||||
| **Serilog.AspNetCore** (10.0.0) | Structured logging | API layer |
|
||||
| **Serilog.Sinks.SQLite** (7.0.0) | SQLite log persistence | API layer |
|
||||
| **Serilog.UI** (3.2.0) + **Serilog.UI.SqliteProvider** (1.1.0) | Web-based log viewer UI | API layer |
|
||||
|
||||
**Critical:** DevExpress requires a license. All PDF operations use `DevExpress.Pdf.PdfDocumentProcessor`.
|
||||
|
||||
@@ -275,10 +278,20 @@ Do NOT add `if (result.IsSuccess)` checks. Throw exceptions for errors. The midd
|
||||
|-----------|--------|-------|
|
||||
| **PdfValidationController** | ✅ DONE | 13 (7 validate + 6 validate-pdfa) |
|
||||
| **SwissQrCodeController** | ✅ DONE | 2 |
|
||||
| **PdfAttachmentController** | ⏳ Pending | 0 |
|
||||
| **PdfOperationsController** | ⏳ Pending | 0 |
|
||||
| **PdfAttachmentController** | ⏳ Partial (2/3 endpoints) | 10 (4 check + 6 extract) |
|
||||
| **PdfOperationsController** | ⏳ Partial (3/3 endpoints, integration tests pending) | 29 (7 merge + 22 annotate: 12 unit + 10 integration) |
|
||||
| **PdfConversionController** | ⏳ Pending | 0 |
|
||||
|
||||
**PdfAttachmentController Status:**
|
||||
- ✅ `POST /api/pdf/attachments/check` - DONE (with multipart + Base64 support)
|
||||
- ✅ `POST /api/pdf/attachments/extract` - DONE (Phase 1, Priority 4) - Returns ZIP with all attachments
|
||||
- ⏳ `POST /api/pdf/attachments/add` - TODO (Phase 2, Priority 7)
|
||||
|
||||
**PdfOperationsController Status:**
|
||||
- ✅ `POST /api/pdf/operations/merge` - DONE (Phase 1, Priority 5) - Merges multiple PDFs with optional page ranges (multipart + Base64)
|
||||
- ✅ `POST /api/pdf/operations/annotate` - DONE (Phase 2, Priority 6) - Adds annotations (TextMarkup/FreeText/StickyNote/Circle/Square) with multipart + Base64 support
|
||||
- ✅ `POST /api/pdf/operations/stamp` - DONE (Phase 2, Priority 6) - Adds text/image/predefined stamps (multipart + Base64 support, origin/rotation/opacity/placement)
|
||||
|
||||
**Note:** PdfRenderController removed - moved to .NET client library.
|
||||
|
||||
---
|
||||
@@ -375,10 +388,19 @@ DocumentOperator.Tests/
|
||||
- Matches Application layer structure exactly
|
||||
|
||||
4. **Test Pyramid:**
|
||||
- **Unit tests (15):** Fast, isolated, many scenarios
|
||||
- **Integration tests (15):** Slower, full pipeline, critical paths only
|
||||
- **Unit tests (60+):** Fast, isolated, many scenarios
|
||||
- **Integration tests (27):** Slower, full pipeline, critical paths only
|
||||
|
||||
**Test count:** 30 tests total (as of Feature 3 - PDF/A Validation)
|
||||
**Test count:** 101 passed, 7 skipped (as of Feature 6 - PDF Annotation)
|
||||
|
||||
**Test breakdown by feature:**
|
||||
- Feature 1 (PDF Validation): 13 integration tests
|
||||
- Feature 2 (Swiss QR Code): 2 integration tests
|
||||
- Feature 3 (PDF/A Validation): 6 integration tests (validate-pdfa) + 4 unit tests (handler)
|
||||
- Feature 4 (PDF Attachments): 10 tests (4 check + 6 extract integration)
|
||||
- Feature 5 (PDF Merge): 7 integration + 10 unit tests (DevExpressPdfProcessor)
|
||||
- Feature 6 (PDF Annotation): 10 integration + 12 unit tests (DevExpressPdfProcessor)
|
||||
- Infrastructure: 37 unit tests (DevExpressPdfProcessor for validation, attachments, merge, annotation)
|
||||
|
||||
**FluentValidation in tests:**
|
||||
- Base64 format validation happens in `ValidatePdfQueryValidator` and `ValidatePdfAQueryValidator`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using System.Reflection;
|
||||
|
||||
namespace DocumentOperator.API.Configuration
|
||||
@@ -12,16 +13,22 @@ namespace DocumentOperator.API.Configuration
|
||||
/// Adds Swagger documentation generation to the service collection.
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add Swagger to.</param>
|
||||
/// <param name="configuration">Configuration to read SwaggerSettings from.</param>
|
||||
/// <returns>The modified service collection.</returns>
|
||||
public static IServiceCollection AddSwaggerDocumentation(this IServiceCollection services)
|
||||
public static IServiceCollection AddSwaggerDocumentation(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
var swaggerSettings = configuration.GetSection(SwaggerSettings.SectionName).Get<SwaggerSettings>()
|
||||
?? new SwaggerSettings();
|
||||
|
||||
services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new OpenApiInfo
|
||||
options.SwaggerDoc(swaggerSettings.Version, new OpenApiInfo
|
||||
{
|
||||
Title = "DD Document Operator API",
|
||||
Version = "v1",
|
||||
Description = "PDF Verarbeitungs-Service für Validierung, Stempel, Zertifikate, Anhänge & Zusammenführung"
|
||||
Title = swaggerSettings.Title,
|
||||
Version = swaggerSettings.Version,
|
||||
Description = swaggerSettings.Description
|
||||
});
|
||||
|
||||
// Resolve conflicting actions: Keep first variant
|
||||
|
||||
33
DocumentOperator.API/Configuration/SwaggerSettings.cs
Normal file
33
DocumentOperator.API/Configuration/SwaggerSettings.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace DocumentOperator.API.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration settings for Swagger/OpenAPI documentation.
|
||||
/// </summary>
|
||||
public class SwaggerSettings
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public const string SectionName = "SwaggerSettings";
|
||||
|
||||
/// <summary>
|
||||
/// Enable Swagger UI in Production environment.
|
||||
/// Default: true (allows production testing/debugging).
|
||||
/// </summary>
|
||||
public bool EnableInProduction { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// API title displayed in Swagger UI.
|
||||
/// </summary>
|
||||
public string Title { get; set; } = "DocumentOperator API";
|
||||
|
||||
/// <summary>
|
||||
/// API version.
|
||||
/// </summary>
|
||||
public string Version { get; set; } = "v1";
|
||||
|
||||
/// <summary>
|
||||
/// API description displayed in Swagger UI.
|
||||
/// </summary>
|
||||
public string Description { get; set; } = "PDF document processing service";
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using DocumentOperator.Application.CheckPdfAttachments.Queries;
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Application.ExtractPdfAttachments;
|
||||
using DocumentOperator.Domain.Common.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -33,13 +35,11 @@ public class PdfAttachmentController(IMediator mediator) : ControllerBase
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert IFormFile to byte array
|
||||
using var memoryStream = new MemoryStream();
|
||||
await file.CopyToAsync(memoryStream, cancellationToken);
|
||||
byte[] pdfBytes = memoryStream.ToArray();
|
||||
// Use IFormFile stream directly (no intermediate byte[] conversion)
|
||||
using var pdfStream = file.OpenReadStream();
|
||||
|
||||
// Send query to MediatR (ValidationBehavior runs automatically)
|
||||
var query = new CheckPdfAttachmentsQuery { PdfBytes = pdfBytes };
|
||||
var query = new CheckPdfAttachmentsQuery { PdfStream = pdfStream };
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
@@ -64,12 +64,99 @@ public class PdfAttachmentController(IMediator mediator) : ControllerBase
|
||||
[FromBody] CheckPdfAttachmentsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
|
||||
byte[] pdfBytes;
|
||||
try
|
||||
{
|
||||
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
|
||||
}
|
||||
|
||||
using var pdfStream = new MemoryStream(pdfBytes);
|
||||
|
||||
// Send query to MediatR (ValidationBehavior runs automatically)
|
||||
var query = new CheckPdfAttachmentsQuery { Base64Pdf = request.Base64Pdf };
|
||||
var query = new CheckPdfAttachmentsQuery { PdfStream = pdfStream };
|
||||
var result = await mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all embedded files from a PDF and returns them as a ZIP archive.
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="file">The PDF file to extract attachments from</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>ZIP archive containing all extracted attachments</returns>
|
||||
/// <response code="200">Attachments extracted successfully - returns ZIP file</response>
|
||||
/// <response code="400">Invalid input (file missing, not a PDF, or corrupted)</response>
|
||||
/// <response code="404">PDF contains no attachments</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("extract")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ExtractAttachmentsFromFile(
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Use IFormFile stream directly (no intermediate byte[] conversion)
|
||||
using var pdfStream = file.OpenReadStream();
|
||||
|
||||
// Send command to MediatR
|
||||
var command = new ExtractPdfAttachmentsCommand { PdfStream = pdfStream };
|
||||
byte[] zipBytes = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Return ZIP file
|
||||
return File(zipBytes, "application/zip", "attachments.zip");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all embedded files from a PDF and returns them as a ZIP archive.
|
||||
/// Supports Base64-encoded PDF via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing Base64-encoded PDF</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>ZIP archive containing all extracted attachments</returns>
|
||||
/// <response code="200">Attachments extracted successfully - returns ZIP file</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, not a PDF, or corrupted)</response>
|
||||
/// <response code="404">PDF contains no attachments</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("extract")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ExtractAttachmentsFromBase64(
|
||||
[FromBody] ExtractPdfAttachmentsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
|
||||
byte[] pdfBytes;
|
||||
try
|
||||
{
|
||||
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
|
||||
}
|
||||
|
||||
using var pdfStream = new MemoryStream(pdfBytes);
|
||||
|
||||
// Send command to MediatR
|
||||
var command = new ExtractPdfAttachmentsCommand { PdfStream = pdfStream };
|
||||
byte[] zipBytes = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Return ZIP file
|
||||
return File(zipBytes, "application/zip", "attachments.zip");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -83,3 +170,15 @@ public record CheckPdfAttachmentsRequest
|
||||
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
|
||||
public string Base64Pdf { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF attachment extraction
|
||||
/// </summary>
|
||||
public record ExtractPdfAttachmentsRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document encoded as Base64 string
|
||||
/// </summary>
|
||||
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
|
||||
public required string Base64Pdf { get; init; }
|
||||
}
|
||||
|
||||
728
DocumentOperator.API/Controllers/PdfOperationsController.cs
Normal file
728
DocumentOperator.API/Controllers/PdfOperationsController.cs
Normal file
@@ -0,0 +1,728 @@
|
||||
using DocumentOperator.Application.AddAnnotation;
|
||||
using DocumentOperator.Application.AddStamp;
|
||||
using DocumentOperator.Application.MergePdfs;
|
||||
using DocumentOperator.Domain.Common.Exceptions;
|
||||
using DocumentOperator.Domain.Models.ValueObjects;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DocumentOperator.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controller for PDF operations (merge, stamp, annotate).
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/pdf/operations")]
|
||||
public class PdfOperationsController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Merges multiple PDF files into a single PDF.
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="files">PDF files to merge (minimum 2 required)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Merged PDF file</returns>
|
||||
/// <response code="200">PDFs merged successfully - returns merged PDF</response>
|
||||
/// <response code="400">Invalid input (fewer than 2 files, corrupted PDF)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("merge", Name = "MergeFromFiles")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> MergeFromFiles(
|
||||
[FromForm] List<IFormFile> files,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert IFormFile[] to Stream[] (use OpenReadStream directly - no buffering)
|
||||
var streams = files.Select(f => f.OpenReadStream()).ToList();
|
||||
|
||||
// Send command to MediatR (no page ranges for now - multipart binding is complex)
|
||||
var command = new MergePdfsCommand
|
||||
{
|
||||
PdfStreams = streams,
|
||||
PageRanges = null
|
||||
};
|
||||
|
||||
byte[] mergedPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Return merged PDF
|
||||
return File(mergedPdf, "application/pdf", "merged.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple PDF files into a single PDF.
|
||||
/// Supports Base64-encoded PDFs via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing Base64-encoded PDFs and optional page ranges</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Merged PDF file</returns>
|
||||
/// <response code="200">PDFs merged successfully - returns merged PDF</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, fewer than 2 files, corrupted PDF, invalid page range)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("merge", Name = "MergeFromBase64")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> MergeFromBase64(
|
||||
[FromBody] MergePdfsBase64Request request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert Base64[] to MemoryStream[]
|
||||
List<Stream> streams = [];
|
||||
try
|
||||
{
|
||||
foreach (var base64Pdf in request.Base64Pdfs)
|
||||
{
|
||||
byte[] pdfBytes = Convert.FromBase64String(base64Pdf);
|
||||
streams.Add(new MemoryStream(pdfBytes));
|
||||
}
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
// Dispose opened streams on error
|
||||
foreach (var stream in streams) stream.Dispose();
|
||||
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
|
||||
}
|
||||
|
||||
var command = new MergePdfsCommand
|
||||
{
|
||||
PdfStreams = streams,
|
||||
PageRanges = request.PageRanges
|
||||
};
|
||||
|
||||
byte[] mergedPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Cleanup streams (important for MemoryStreams we created)
|
||||
foreach (var stream in streams) stream.Dispose();
|
||||
|
||||
return File(mergedPdf, "application/pdf", "merged.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an annotation to a PDF document.
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="request">Multipart form data containing PDF file and annotation parameters</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Annotated PDF file</returns>
|
||||
/// <response code="200">Annotation added successfully - returns annotated PDF</response>
|
||||
/// <response code="400">Invalid input (invalid page number, missing required parameters)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("annotate", Name = "AnnotateFromFile")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> AnnotateFromFile(
|
||||
[FromForm] AddAnnotationMultipartRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Calculate X2, Y2 from Width/Height if provided
|
||||
double x2 = request.X2 ?? request.X1 + (request.Width ?? throw new BadRequestException("Either X2 or Width must be provided"));
|
||||
double y2 = request.Y2 ?? request.Y1 + (request.Height ?? throw new BadRequestException("Either Y2 or Height must be provided"));
|
||||
|
||||
var command = new AddAnnotationCommand
|
||||
{
|
||||
PdfStream = request.File.OpenReadStream(),
|
||||
AnnotationType = request.AnnotationType,
|
||||
PageNumber = request.PageNumber,
|
||||
Rectangle = (request.X1, request.Y1, x2, y2),
|
||||
Content = request.Content,
|
||||
Author = request.Author,
|
||||
Color = request.Color,
|
||||
TextMarkupStyle = request.TextMarkupStyle,
|
||||
Origin = request.Origin
|
||||
};
|
||||
|
||||
byte[] annotatedPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
return File(annotatedPdf, "application/pdf", "annotated.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an annotation to a PDF document.
|
||||
/// Supports Base64-encoded PDF via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="command">Command containing all annotation parameters (including Base64 PDF)</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Annotated PDF file</returns>
|
||||
/// <response code="200">Annotation added successfully - returns annotated PDF</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, invalid page number, missing required parameters)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("annotate", Name = "AnnotateFromBase64")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> AnnotateFromBase64(
|
||||
[FromBody] AddAnnotationBase64Command command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert Base64 to MemoryStream
|
||||
Stream pdfStream;
|
||||
try
|
||||
{
|
||||
byte[] pdfBytes = Convert.FromBase64String(command.Base64Pdf);
|
||||
pdfStream = new MemoryStream(pdfBytes);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
|
||||
}
|
||||
|
||||
// Calculate X2, Y2 from Width/Height if provided
|
||||
double x2 = command.X2 ?? command.X1 + (command.Width ?? throw new BadRequestException("Either X2 or Width must be provided"));
|
||||
double y2 = command.Y2 ?? command.Y1 + (command.Height ?? throw new BadRequestException("Either Y2 or Height must be provided"));
|
||||
|
||||
var annotationCommand = new AddAnnotationCommand
|
||||
{
|
||||
PdfStream = pdfStream,
|
||||
AnnotationType = command.AnnotationType,
|
||||
PageNumber = command.PageNumber,
|
||||
Rectangle = (command.X1, command.Y1, x2, y2),
|
||||
Content = command.Content,
|
||||
Author = command.Author,
|
||||
Color = command.Color,
|
||||
TextMarkupStyle = command.TextMarkupStyle,
|
||||
Origin = command.Origin
|
||||
};
|
||||
|
||||
byte[] annotatedPdf = await mediator.Send(annotationCommand, cancellationToken);
|
||||
|
||||
// Cleanup stream
|
||||
pdfStream.Dispose();
|
||||
|
||||
return File(annotatedPdf, "application/pdf", "annotated.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a stamp (text, image, or predefined) to PDF pages.
|
||||
/// Supports multipart/form-data file upload.
|
||||
/// </summary>
|
||||
/// <param name="request">Multipart form data containing PDF file and stamp parameters</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Stamped PDF file</returns>
|
||||
/// <response code="200">Stamp added successfully - returns stamped PDF</response>
|
||||
/// <response code="400">Invalid input (invalid page number, missing required parameters, invalid image format)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("stamp", Name = "AddStampFromFile")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> AddStampFromFile(
|
||||
[FromForm] AddStampMultipartRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert ImageFile to byte[] if provided
|
||||
byte[]? imageBytes = null;
|
||||
if (request.ImageFile != null)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await request.ImageFile.CopyToAsync(ms, cancellationToken);
|
||||
imageBytes = ms.ToArray();
|
||||
}
|
||||
|
||||
var command = new AddStampCommand
|
||||
{
|
||||
PdfStream = request.File.OpenReadStream(),
|
||||
StampType = request.StampType,
|
||||
PageNumbers = request.PageNumbers,
|
||||
Position = (request.X, request.Y),
|
||||
Size = request.Width.HasValue && request.Height.HasValue
|
||||
? (request.Width.Value, request.Height.Value)
|
||||
: null,
|
||||
Origin = request.Origin,
|
||||
Text = request.Text,
|
||||
FontName = request.FontName,
|
||||
FontSize = request.FontSize,
|
||||
Color = request.Color,
|
||||
Opacity = request.Opacity,
|
||||
Rotation = request.Rotation,
|
||||
Placement = request.Placement,
|
||||
ImageBytes = imageBytes,
|
||||
PredefinedType = request.PredefinedType
|
||||
};
|
||||
|
||||
byte[] stampedPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
return File(stampedPdf, "application/pdf", "stamped.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a stamp (text, image, or predefined) to PDF pages.
|
||||
/// Supports Base64-encoded PDF and image via JSON payload.
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing Base64-encoded PDF, stamp parameters, and optional Base64 image</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Stamped PDF file</returns>
|
||||
/// <response code="200">Stamp added successfully - returns stamped PDF</response>
|
||||
/// <response code="400">Invalid input (Base64 format error, invalid page number, missing required parameters)</response>
|
||||
/// <response code="500">Internal server error during PDF processing</response>
|
||||
[HttpPost("stamp", Name = "AddStampFromBase64")]
|
||||
[Consumes("application/json")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> AddStampFromBase64(
|
||||
[FromBody] AddStampBase64Request request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert Base64 PDF to MemoryStream
|
||||
Stream pdfStream;
|
||||
try
|
||||
{
|
||||
byte[] pdfBytes = Convert.FromBase64String(request.Base64Pdf);
|
||||
pdfStream = new MemoryStream(pdfBytes);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadRequestException("Invalid Base64 PDF format: " + ex.Message);
|
||||
}
|
||||
|
||||
// Convert Base64 image to byte[] if provided
|
||||
byte[]? imageBytes = null;
|
||||
if (!string.IsNullOrWhiteSpace(request.Base64Image))
|
||||
{
|
||||
try
|
||||
{
|
||||
imageBytes = Convert.FromBase64String(request.Base64Image);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
pdfStream.Dispose();
|
||||
throw new BadRequestException("Invalid Base64 image format: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
var command = new AddStampCommand
|
||||
{
|
||||
PdfStream = pdfStream,
|
||||
StampType = request.StampType,
|
||||
PageNumbers = request.PageNumbers,
|
||||
Position = (request.X, request.Y),
|
||||
Size = request.Width.HasValue && request.Height.HasValue
|
||||
? (request.Width.Value, request.Height.Value)
|
||||
: null,
|
||||
Origin = request.Origin,
|
||||
Text = request.Text,
|
||||
FontName = request.FontName,
|
||||
FontSize = request.FontSize,
|
||||
Color = request.Color,
|
||||
Opacity = request.Opacity,
|
||||
Rotation = request.Rotation,
|
||||
Placement = request.Placement,
|
||||
ImageBytes = imageBytes,
|
||||
PredefinedType = request.PredefinedType
|
||||
};
|
||||
|
||||
byte[] stampedPdf = await mediator.Send(command, cancellationToken);
|
||||
|
||||
// Cleanup stream
|
||||
pdfStream.Dispose();
|
||||
|
||||
return File(stampedPdf, "application/pdf", "stamped.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for multipart/form-data annotation operation
|
||||
/// </summary>
|
||||
public class AddAnnotationMultipartRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF file to annotate
|
||||
/// </summary>
|
||||
public required IFormFile File { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of annotation (TextMarkup, FreeText, StickyNote, Circle, Square)
|
||||
/// </summary>
|
||||
public required AnnotationType AnnotationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target page number (1-indexed)
|
||||
/// </summary>
|
||||
public required int PageNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle X1 coordinate (left)
|
||||
/// </summary>
|
||||
public required double X1 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle Y1 coordinate (top or bottom depending on Origin)
|
||||
/// </summary>
|
||||
public required double Y1 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle X2 coordinate (right). Optional if Width is provided.
|
||||
/// </summary>
|
||||
public double? X2 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle Y2 coordinate (bottom or top depending on Origin). Optional if Height is provided.
|
||||
/// </summary>
|
||||
public double? Y2 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle width. Alternative to X2 (X2 = X1 + Width). Optional if X2 is provided.
|
||||
/// </summary>
|
||||
public double? Width { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle height. Alternative to Y2 (Y2 = Y1 + Height). Optional if Y2 is provided.
|
||||
/// </summary>
|
||||
public double? Height { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotation content (required for FreeText/StickyNote)
|
||||
/// </summary>
|
||||
public string? Content { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Author name (optional)
|
||||
/// </summary>
|
||||
public string? Author { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hex color (6 digits, e.g., "FF0000" for red)
|
||||
/// </summary>
|
||||
public string? Color { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Markup style (Highlight/Underline/Strikeout, required for TextMarkup)
|
||||
/// </summary>
|
||||
public TextMarkupStyle? TextMarkupStyle { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
|
||||
/// </summary>
|
||||
public AnnotationOrigin Origin { get; set; } = AnnotationOrigin.BottomLeft;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF merge operation (API layer only - converts to MergePdfsCommand)
|
||||
/// </summary>
|
||||
public record MergePdfsBase64Request
|
||||
{
|
||||
/// <summary>
|
||||
/// Array of Base64-encoded PDF files (minimum 2 required)
|
||||
/// </summary>
|
||||
/// <example>["JVBERi0xLjQK...", "JVBERi0xLjQK..."]</example>
|
||||
public required List<string> Base64Pdfs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional page ranges per PDF (null = all pages).
|
||||
/// Format: "1-3,5" means pages 1, 2, 3, and 5.
|
||||
/// If provided, array length must match Base64Pdfs length.
|
||||
/// </summary>
|
||||
/// <example>["1-2", "1,3,5", null]</example>
|
||||
public List<string?>? PageRanges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF annotation (API layer only - converts to AddAnnotationCommand)
|
||||
/// </summary>
|
||||
public record AddAnnotationBase64Command
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64-encoded PDF file
|
||||
/// </summary>
|
||||
/// <example>"JVBERi0xLjQK..."</example>
|
||||
public required string Base64Pdf { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of annotation to add
|
||||
/// </summary>
|
||||
/// <example>TextMarkup</example>
|
||||
public required AnnotationType AnnotationType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target page number (1-indexed)
|
||||
/// </summary>
|
||||
/// <example>1</example>
|
||||
public required int PageNumber { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle X1 coordinate (left)
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public required double X1 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle Y1 coordinate (top or bottom depending on Origin)
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public required double Y1 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle X2 coordinate (right). Optional if Width is provided.
|
||||
/// </summary>
|
||||
/// <example>200.0</example>
|
||||
public double? X2 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle Y2 coordinate (bottom or top depending on Origin). Optional if Height is provided.
|
||||
/// </summary>
|
||||
/// <example>120.0</example>
|
||||
public double? Y2 { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle width. Alternative to X2 (X2 = X1 + Width). Optional if X2 is provided.
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public double? Width { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle height. Alternative to Y2 (Y2 = Y1 + Height). Optional if Y2 is provided.
|
||||
/// </summary>
|
||||
/// <example>20.0</example>
|
||||
public double? Height { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Annotation content (required for FreeText and StickyNote)
|
||||
/// </summary>
|
||||
/// <example>"Important text to highlight"</example>
|
||||
public string? Content { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Author name (optional)
|
||||
/// </summary>
|
||||
/// <example>"John Doe"</example>
|
||||
public string? Author { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hex color (6 digits, e.g., "FF0000" for red). Optional - defaults vary by annotation type.
|
||||
/// </summary>
|
||||
/// <example>"FFFF00"</example>
|
||||
public string? Color { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Text markup style (Highlight, Underline, or Strikeout). Required for TextMarkup annotations.
|
||||
/// </summary>
|
||||
/// <example>Highlight</example>
|
||||
public TextMarkupStyle? TextMarkupStyle { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
|
||||
/// </summary>
|
||||
/// <example>BottomLeft</example>
|
||||
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for multipart/form-data stamp operation
|
||||
/// </summary>
|
||||
public class AddStampMultipartRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF file to stamp
|
||||
/// </summary>
|
||||
public required IFormFile File { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of stamp (Text, Image, or Predefined)
|
||||
/// </summary>
|
||||
public required StampType StampType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target page numbers (1-indexed). Null or empty = all pages.
|
||||
/// </summary>
|
||||
/// <example>[1, 3, 5]</example>
|
||||
public int[]? PageNumbers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp position X coordinate
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public required double X { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp position Y coordinate
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public required double Y { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp width (optional, auto-size for images if not specified)
|
||||
/// </summary>
|
||||
/// <example>200.0</example>
|
||||
public double? Width { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp height (optional, auto-size for images if not specified)
|
||||
/// </summary>
|
||||
/// <example>50.0</example>
|
||||
public double? Height { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
|
||||
/// </summary>
|
||||
/// <example>BottomLeft</example>
|
||||
public AnnotationOrigin Origin { get; set; } = AnnotationOrigin.BottomLeft;
|
||||
|
||||
/// <summary>
|
||||
/// Text content (required for Text stamps)
|
||||
/// </summary>
|
||||
/// <example>"CONFIDENTIAL"</example>
|
||||
public string? Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Font name (default: Arial)
|
||||
/// </summary>
|
||||
/// <example>"Arial"</example>
|
||||
public string? FontName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Font size in points (default: 12)
|
||||
/// </summary>
|
||||
/// <example>24.0</example>
|
||||
public double? FontSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hex color (6 digits, e.g., "FF0000" for red, default: "000000")
|
||||
/// </summary>
|
||||
/// <example>"FF0000"</example>
|
||||
public string? Color { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Opacity (0.0 = transparent, 1.0 = opaque, default: 0.5)
|
||||
/// </summary>
|
||||
/// <example>0.5</example>
|
||||
public double? Opacity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Rotation angle in degrees (0-360, default: 0)
|
||||
/// </summary>
|
||||
/// <example>45.0</example>
|
||||
public double? Rotation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp placement (Foreground = on top, Background = watermark effect, default: Foreground)
|
||||
/// </summary>
|
||||
/// <example>Foreground</example>
|
||||
public StampPlacement Placement { get; set; } = StampPlacement.Foreground;
|
||||
|
||||
/// <summary>
|
||||
/// Image file (required for Image stamps, PNG/JPEG)
|
||||
/// </summary>
|
||||
public IFormFile? ImageFile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Predefined stamp type (required for Predefined stamps)
|
||||
/// </summary>
|
||||
/// <example>Confidential</example>
|
||||
public PredefinedStampType? PredefinedType { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF stamp operation (API layer only - converts to AddStampCommand)
|
||||
/// </summary>
|
||||
public record AddStampBase64Request
|
||||
{
|
||||
/// <summary>
|
||||
/// Base64-encoded PDF file
|
||||
/// </summary>
|
||||
/// <example>"JVBERi0xLjQK..."</example>
|
||||
public required string Base64Pdf { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Type of stamp (Text, Image, or Predefined)
|
||||
/// </summary>
|
||||
/// <example>Text</example>
|
||||
public required StampType StampType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target page numbers (1-indexed). Null or empty = all pages.
|
||||
/// </summary>
|
||||
/// <example>[1, 3, 5]</example>
|
||||
public int[]? PageNumbers { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp position X coordinate
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public required double X { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp position Y coordinate
|
||||
/// </summary>
|
||||
/// <example>100.0</example>
|
||||
public required double Y { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp width (optional, auto-size for images if not specified)
|
||||
/// </summary>
|
||||
/// <example>200.0</example>
|
||||
public double? Width { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp height (optional, auto-size for images if not specified)
|
||||
/// </summary>
|
||||
/// <example>50.0</example>
|
||||
public double? Height { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
|
||||
/// </summary>
|
||||
/// <example>BottomLeft</example>
|
||||
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
|
||||
|
||||
/// <summary>
|
||||
/// Text content (required for Text stamps)
|
||||
/// </summary>
|
||||
/// <example>"CONFIDENTIAL"</example>
|
||||
public string? Text { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Font name (default: Arial)
|
||||
/// </summary>
|
||||
/// <example>"Arial"</example>
|
||||
public string? FontName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Font size in points (default: 12)
|
||||
/// </summary>
|
||||
/// <example>24.0</example>
|
||||
public double? FontSize { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Hex color (6 digits, e.g., "FF0000" for red, default: "000000")
|
||||
/// </summary>
|
||||
/// <example>"FF0000"</example>
|
||||
public string? Color { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Opacity (0.0 = transparent, 1.0 = opaque, default: 0.5)
|
||||
/// </summary>
|
||||
/// <example>0.5</example>
|
||||
public double? Opacity { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rotation angle in degrees (0-360, default: 0)
|
||||
/// </summary>
|
||||
/// <example>45.0</example>
|
||||
public double? Rotation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stamp placement (Foreground = on top, Background = watermark effect, default: Foreground)
|
||||
/// </summary>
|
||||
/// <example>Foreground</example>
|
||||
public StampPlacement Placement { get; init; } = StampPlacement.Foreground;
|
||||
|
||||
/// <summary>
|
||||
/// Base64-encoded image (required for Image stamps, PNG/JPEG)
|
||||
/// </summary>
|
||||
/// <example>"iVBORw0KGgoAAAANSUhEUgAA..."</example>
|
||||
public string? Base64Image { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Predefined stamp type (required for Predefined stamps)
|
||||
/// </summary>
|
||||
/// <example>Confidential</example>
|
||||
public PredefinedStampType? PredefinedType { get; init; }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Application.ValidatePdf.Queries;
|
||||
using DocumentOperator.Application.ValidatePdfA.Queries;
|
||||
using DocumentOperator.Domain.Common.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -42,13 +43,11 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
// Convert IFormFile to byte array
|
||||
using var memoryStream = new MemoryStream();
|
||||
await file.CopyToAsync(memoryStream, cancellationToken);
|
||||
byte[] pdfBytes = memoryStream.ToArray();
|
||||
|
||||
// Use IFormFile stream directly (no intermediate byte[] conversion)
|
||||
using var pdfStream = file.OpenReadStream();
|
||||
|
||||
// Direct pass-through to MediatR
|
||||
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
|
||||
var query = new ValidatePdfQuery { PdfStream = pdfStream };
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
@@ -57,7 +56,7 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
|
||||
/// <summary>
|
||||
/// Validates a PDF document and returns metadata (Base64 JSON)
|
||||
/// </summary>
|
||||
/// <param name="query">PDF as Base64 string</param>
|
||||
/// <param name="request">Request containing Base64-encoded PDF</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>PDF metadata (page count, file size, PDF version, attachments)</returns>
|
||||
/// <response code="200">PDF is valid, metadata returned</response>
|
||||
@@ -69,10 +68,24 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ValidateFromBase64(
|
||||
[FromBody] ValidatePdfQuery query,
|
||||
[FromBody] ValidatePdfBase64Request request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
|
||||
byte[] pdfBytes;
|
||||
try
|
||||
{
|
||||
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
|
||||
}
|
||||
|
||||
using var pdfStream = new MemoryStream(pdfBytes);
|
||||
|
||||
// Direct pass-through to MediatR
|
||||
var query = new ValidatePdfQuery { PdfStream = pdfStream };
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
@@ -106,13 +119,11 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
// Convert IFormFile to byte array
|
||||
using var memoryStream = new MemoryStream();
|
||||
await file.CopyToAsync(memoryStream, cancellationToken);
|
||||
byte[] pdfBytes = memoryStream.ToArray();
|
||||
|
||||
// Use IFormFile stream directly (no intermediate byte[] conversion)
|
||||
using var pdfStream = file.OpenReadStream();
|
||||
|
||||
// Direct pass-through to MediatR
|
||||
var query = new ValidatePdfAQuery { PdfBytes = pdfBytes };
|
||||
var query = new ValidatePdfAQuery { PdfStream = pdfStream };
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
@@ -121,7 +132,7 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
|
||||
/// <summary>
|
||||
/// Validates a PDF/A document and checks conformance level (Base64 JSON)
|
||||
/// </summary>
|
||||
/// <param name="query">PDF as Base64 string</param>
|
||||
/// <param name="request">Request containing Base64-encoded PDF</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>PDF/A metadata (conformance level, errors, warnings)</returns>
|
||||
/// <response code="200">PDF/A validation completed, results returned</response>
|
||||
@@ -133,12 +144,50 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ValidatePdfAFromBase64(
|
||||
[FromBody] ValidatePdfAQuery query,
|
||||
[FromBody] ValidatePdfABase64Request request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
|
||||
byte[] pdfBytes;
|
||||
try
|
||||
{
|
||||
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
|
||||
}
|
||||
|
||||
using var pdfStream = new MemoryStream(pdfBytes);
|
||||
|
||||
// Direct pass-through to MediatR
|
||||
var query = new ValidatePdfAQuery { PdfStream = pdfStream };
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF validation
|
||||
/// </summary>
|
||||
public record ValidatePdfBase64Request
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document encoded as Base64 string
|
||||
/// </summary>
|
||||
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
|
||||
public string Base64Pdf { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded PDF/A validation
|
||||
/// </summary>
|
||||
public record ValidatePdfABase64Request
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document encoded as Base64 string
|
||||
/// </summary>
|
||||
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
|
||||
public string Base64Pdf { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Application.SwissQrCode.Queries;
|
||||
using DocumentOperator.Domain.Common.Exceptions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -17,7 +18,7 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
|
||||
/// Extracts Swiss QR Code from the last page of a PDF document (multipart/form-data)
|
||||
/// </summary>
|
||||
/// <param name="file">PDF file containing Swiss QR Code</param>
|
||||
/// <param name="raw"></param>
|
||||
/// <param name="raw">If true, returns raw QR text lines instead of parsed Bill object</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Swiss QR Code data (IBAN, amount, creditor, debtor, reference, etc.)</returns>
|
||||
/// <response code="200">Swiss QR Code extracted successfully</response>
|
||||
@@ -43,15 +44,13 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
|
||||
// Convert IFormFile to byte array
|
||||
using var memoryStream = new MemoryStream();
|
||||
await file.CopyToAsync(memoryStream, cancellationToken);
|
||||
byte[] pdfBytes = memoryStream.ToArray();
|
||||
// Use IFormFile stream directly (no intermediate byte[] conversion)
|
||||
using var pdfStream = file.OpenReadStream();
|
||||
|
||||
// Direct pass-through to MediatR
|
||||
var query = new ExtractSwissQrCodeQuery
|
||||
{
|
||||
PdfBytes = pdfBytes
|
||||
PdfStream = pdfStream
|
||||
};
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
@@ -61,8 +60,8 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
|
||||
/// <summary>
|
||||
/// Extracts Swiss QR Code from the last page of a PDF document (Base64 JSON)
|
||||
/// </summary>
|
||||
/// <param name="query">References array + PDF as Base64 string</param>
|
||||
/// <param name="raw"></param>
|
||||
/// <param name="request">Request containing Base64-encoded PDF</param>
|
||||
/// <param name="raw">If true, returns raw QR text lines instead of parsed Bill object</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Swiss QR Code data (IBAN, amount, creditor, debtor, reference, etc.)</returns>
|
||||
/// <response code="200">Swiss QR Code extracted successfully</response>
|
||||
@@ -76,13 +75,39 @@ public class SwissQrCodeController(IMediator Mediator) : ControllerBase
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> ExtractFromBase64(
|
||||
[FromBody] ExtractSwissQrCodeQuery query,
|
||||
[FromBody] ExtractSwissQrCodeBase64Request request,
|
||||
[FromQuery] bool raw = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Convert Base64 to stream (wrap in try-catch to throw BadRequestException)
|
||||
byte[] pdfBytes;
|
||||
try
|
||||
{
|
||||
pdfBytes = Convert.FromBase64String(request.Base64Pdf);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
|
||||
}
|
||||
|
||||
using var pdfStream = new MemoryStream(pdfBytes);
|
||||
|
||||
// Direct pass-through to MediatR
|
||||
var query = new ExtractSwissQrCodeQuery { PdfStream = pdfStream };
|
||||
var result = await Mediator.Send(query, cancellationToken);
|
||||
|
||||
return Ok(raw ? result.RawLines : result.Bill);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Base64-encoded Swiss QR Code extraction
|
||||
/// </summary>
|
||||
public record ExtractSwissQrCodeBase64Request
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document encoded as Base64 string
|
||||
/// </summary>
|
||||
/// <example>JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c...</example>
|
||||
public required string Base64Pdf { get; init; }
|
||||
}
|
||||
|
||||
@@ -4,15 +4,30 @@
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<PackageId>DocumentOperator.API</PackageId>
|
||||
<Authors>Digital Data GmbH</Authors>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>DocumentOperator.API</Product>
|
||||
<Version>1.0.0</Version>
|
||||
<FileVersion>1.0.0.0</FileVersion>
|
||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||
<InformationalVersion>1.0.0</InformationalVersion>
|
||||
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
|
||||
<Description>PDF Document Operations REST API - Validation, Swiss QR Code extraction, attachments, merge, annotation, stamp operations powered by DevExpress Office File API</Description>
|
||||
<PackageTags>pdf document operator validation swiss-qr-code annotations stamp devexpress</PackageTags>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Http" Version="8.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="8.0.28" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="1.2.58" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.SQLite" Version="7.0.0" />
|
||||
<PackageReference Include="Serilog.UI" Version="3.2.0" />
|
||||
<PackageReference Include="Serilog.UI.SqliteProvider" Version="1.1.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using DocumentOperator.Domain.Common.Exceptions;
|
||||
using DocumentOperator.Domain.Exceptions;
|
||||
using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Net;
|
||||
@@ -67,7 +66,7 @@ public class ExceptionHandlingMiddleware(RequestDelegate Next)
|
||||
}
|
||||
),
|
||||
|
||||
// Not Found Exception (404 Not Found)
|
||||
// Bad Request Exception (400 Bad Request)
|
||||
BadRequestException badReqEx => (
|
||||
HttpStatusCode.BadRequest,
|
||||
new ProblemDetails
|
||||
@@ -93,19 +92,6 @@ public class ExceptionHandlingMiddleware(RequestDelegate Next)
|
||||
}
|
||||
),
|
||||
|
||||
// Swiss QR Code Not Found Exception (404 Not Found)
|
||||
SwissQrCodeNotFoundException qrNotFoundEx => (
|
||||
HttpStatusCode.NotFound,
|
||||
new ProblemDetails
|
||||
{
|
||||
Type = "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.4",
|
||||
Title = "Swiss QR Code Not Found",
|
||||
Status = (int)HttpStatusCode.NotFound,
|
||||
Detail = qrNotFoundEx.Message,
|
||||
Instance = context.Request.Path
|
||||
}
|
||||
),
|
||||
|
||||
// Generic Exception (500 Internal Server Error)
|
||||
_ => (
|
||||
HttpStatusCode.InternalServerError,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
using Serilog;
|
||||
using Serilog.Ui.Core.Extensions;
|
||||
using Serilog.Ui.SqliteDataProvider.Extensions;
|
||||
using Serilog.Ui.Web.Extensions;
|
||||
using Scalar.AspNetCore;
|
||||
using DocumentOperator.Infrastructure.Configuration;
|
||||
using DocumentOperator.Application;
|
||||
using DocumentOperator.Infrastructure;
|
||||
@@ -34,6 +38,9 @@ try
|
||||
builder.Services.Configure<ApiKeySettings>(
|
||||
builder.Configuration.GetSection(ApiKeySettings.SectionName));
|
||||
|
||||
builder.Services.Configure<SwaggerSettings>(
|
||||
builder.Configuration.GetSection(SwaggerSettings.SectionName));
|
||||
|
||||
// ========================================
|
||||
// 3. Services (Clean Architecture Layers)
|
||||
// ========================================
|
||||
@@ -42,10 +49,26 @@ try
|
||||
|
||||
builder.Services.AddControllers(); // Controllers (Controller-based API)
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerDocumentation();
|
||||
builder.Services.AddSwaggerDocumentation(builder.Configuration);
|
||||
|
||||
// ========================================
|
||||
// 4. Build App
|
||||
// 4. Serilog.UI Configuration
|
||||
// ========================================
|
||||
var logDirectory = builder.Configuration.GetValue<string>("Application:LogDirectory")
|
||||
?? throw new InvalidOperationException("Application:LogDirectory not found in configuration.");
|
||||
var sqliteDbPath = Path.Combine(logDirectory, "logs.db");
|
||||
|
||||
builder.Services.AddSerilogUi(logUIOpt =>
|
||||
{
|
||||
logUIOpt.UseSqliteServer(dbOpt =>
|
||||
{
|
||||
dbOpt.WithConnectionString($"Data Source={sqliteDbPath}");
|
||||
dbOpt.WithTable("Logs");
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// 5. Build App
|
||||
// ========================================
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -56,10 +79,32 @@ try
|
||||
// Exception Handling FIRST (catches all exceptions from subsequent middleware)
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
// ========================================
|
||||
// Swagger/OpenAPI (Conditional based on settings)
|
||||
// ========================================
|
||||
var swaggerSettings = builder.Configuration.GetSection(SwaggerSettings.SectionName).Get<SwaggerSettings>()
|
||||
?? new SwaggerSettings();
|
||||
|
||||
if (app.Environment.IsDevelopment() || swaggerSettings.EnableInProduction)
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
// Swagger UI (classic)
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.SwaggerEndpoint($"/swagger/{swaggerSettings.Version}/swagger.json",
|
||||
$"{swaggerSettings.Title} {swaggerSettings.Version}");
|
||||
options.RoutePrefix = "swagger"; // /swagger
|
||||
});
|
||||
|
||||
// Scalar UI (modern alternative)
|
||||
app.MapScalarApiReference(options =>
|
||||
{
|
||||
options
|
||||
.WithTitle(swaggerSettings.Title)
|
||||
.WithTheme(ScalarTheme.DeepSpace)
|
||||
.WithDefaultHttpClient(ScalarTarget.CSharp, ScalarClient.HttpClient);
|
||||
});
|
||||
}
|
||||
|
||||
app.UseSerilogRequestLogging(); // Log HTTP Requests
|
||||
@@ -67,7 +112,12 @@ try
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// ========================================
|
||||
// 6. Endpoints (Controller-based API)
|
||||
// 6. Serilog.UI Dashboard
|
||||
// ========================================
|
||||
app.UseSerilogUi(); // Accessible at /serilog-ui
|
||||
|
||||
// ========================================
|
||||
// 7. Endpoints (Controller-based API)
|
||||
// ========================================
|
||||
app.MapControllers(); // Maps all [ApiController] controllers
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<WebPublishMethod>Package</WebPublishMethod>
|
||||
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
|
||||
<LastUsedPlatform>Any CPU</LastUsedPlatform>
|
||||
<SiteUrlToLaunchAfterPublish />
|
||||
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
|
||||
<ExcludeApp_Data>false</ExcludeApp_Data>
|
||||
<ProjectGuid>c60bc965-d293-ea64-b153-1941f0648df4</ProjectGuid>
|
||||
<DesktopBuildPackageLocation>M:\App&Service\0 DD - Smart UP\DocumentOperator\PreRelease\API\net8\$(Version)\DocumentOperator.API.zip</DesktopBuildPackageLocation>
|
||||
<PackageAsSingleFile>true</PackageAsSingleFile>
|
||||
<DeployIisAppPath>DocumentOperator.API</DeployIisAppPath>
|
||||
<_TargetId>IISWebDeployPackage</_TargetId>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -7,6 +7,17 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
|
||||
"Application": {
|
||||
"LogDirectory": "E:\\LogFiles\\Digital Data\\DocumentOperator.API"
|
||||
},
|
||||
|
||||
"SwaggerSettings": {
|
||||
"EnableInProduction": true,
|
||||
"Title": "DocumentOperator API",
|
||||
"Version": "v1",
|
||||
"Description": "PDF document processing service using DevExpress"
|
||||
},
|
||||
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
@@ -30,6 +41,14 @@
|
||||
"rollingInterval": "Day",
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "SQLite",
|
||||
"Args": {
|
||||
"sqliteDbPath": "E:\\LogFiles\\Digital Data\\DocumentOperator.API\\logs.db",
|
||||
"tableName": "Logs",
|
||||
"storeTimestampInUtc": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
|
||||
@@ -62,5 +81,6 @@
|
||||
"IsActive": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"LuckyPennySoftLicenseKey": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxODE2MTI4MDAwIiwiaWF0IjoiMTc4NDYyNDU1NyIsImFjY291bnRfaWQiOiIwMTk4M2M1OWU0YjM3MjhlYmZkMzEwM2MyYTQ4NmU4NSIsImN1c3RvbWVyX2lkIjoiMDE5ODNjNTllNGIzNzI4ZWJmZDMxMDNjMmE0ODZlODUiLCJzdWJfaWQiOiItIiwiZWRpdGlvbiI6IjAiLCJ0eXBlIjoiMiJ9.IUUO926m9crYGYxMjjKD_n9BnUm-EDyjFIn0YmMUCo7C-QTwvB8WhXP8veTSFsBq-leIIDJ4jyl7Pgc_7ciwg1XhUSIs4mkQroEUaSFCGOxw7Pi41WM8MK5YFSaqLTYYXec9zxgiJbGzABbh3CHTSup3okGnVm_CMoPEs91l2c0A6N1JyZy74urd_tF0KGVKf0MOvzdlQIWLQ8o73S4pTv2N-F6UlzI0fdMtTHMLNNQyr0NdWdnuBk_jMBXO-gy5RE_oCRfMTTYRX2n3XLK6pTfXE0Ct338o9F5sH8Ph2lTXSu56cpdsfZOQZGqCH0LoFp1Dd7RJgIgNmBiTGfvDnA"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using DocumentOperator.Domain.Models.ValueObjects;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentOperator.Application.AddAnnotation;
|
||||
|
||||
/// <summary>
|
||||
/// Command to add an annotation to a PDF document.
|
||||
/// </summary>
|
||||
public record AddAnnotationCommand : IRequest<byte[]>
|
||||
{
|
||||
public required Stream PdfStream { get; init; }
|
||||
public required AnnotationType AnnotationType { get; init; }
|
||||
public required int PageNumber { get; init; }
|
||||
public required (double X1, double Y1, double X2, double Y2) Rectangle { get; init; }
|
||||
public string? Content { get; init; }
|
||||
public string? Author { get; init; }
|
||||
public string? Color { get; init; }
|
||||
public TextMarkupStyle? TextMarkupStyle { get; init; }
|
||||
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for AddAnnotationCommand.
|
||||
/// </summary>
|
||||
public class AddAnnotationHandler(IPdfProcessor pdfProcessor) : IRequestHandler<AddAnnotationCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(AddAnnotationCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.AddAnnotationAsync(
|
||||
request.PdfStream,
|
||||
request.AnnotationType,
|
||||
request.PageNumber,
|
||||
request.Rectangle,
|
||||
request.Content,
|
||||
request.Author,
|
||||
request.Color,
|
||||
request.TextMarkupStyle,
|
||||
request.Origin);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for AddAnnotationCommand.
|
||||
/// </summary>
|
||||
public class AddAnnotationValidator : AbstractValidator<AddAnnotationCommand>
|
||||
{
|
||||
public AddAnnotationValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
|
||||
RuleFor(x => x.PageNumber)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("Page number must be greater than 0");
|
||||
|
||||
RuleFor(x => x.Content)
|
||||
.NotEmpty()
|
||||
.When(x => x.AnnotationType == AnnotationType.FreeText || x.AnnotationType == AnnotationType.StickyNote)
|
||||
.WithMessage("Content is required for FreeText and StickyNote annotations");
|
||||
|
||||
RuleFor(x => x.TextMarkupStyle)
|
||||
.NotNull()
|
||||
.When(x => x.AnnotationType == AnnotationType.TextMarkup)
|
||||
.WithMessage("TextMarkupStyle is required for TextMarkup annotations");
|
||||
|
||||
RuleFor(x => x.Color)
|
||||
.Matches("^[0-9A-Fa-f]{6}$")
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.Color))
|
||||
.WithMessage("Color must be a 6-digit hex value (e.g., 'FF0000' for red)");
|
||||
|
||||
RuleFor(x => x.Rectangle)
|
||||
.Must(r => r.X2 > r.X1 && r.Y2 > r.Y1)
|
||||
.WithMessage("Rectangle coordinates must define a valid area (X2 > X1 and Y2 > Y1)");
|
||||
}
|
||||
}
|
||||
125
DocumentOperator.Application/AddStamp/AddStampCommand.cs
Normal file
125
DocumentOperator.Application/AddStamp/AddStampCommand.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentOperator.Application.AddStamp;
|
||||
|
||||
/// <summary>
|
||||
/// Command to add a stamp (text, image, or predefined) to PDF pages.
|
||||
/// Combines Command, Handler, and Validator in a single file (Vertical Slice pattern).
|
||||
/// </summary>
|
||||
public record AddStampCommand : IRequest<byte[]>
|
||||
{
|
||||
public required Stream PdfStream { get; init; }
|
||||
public required Domain.Models.ValueObjects.StampType StampType { get; init; }
|
||||
public int[]? PageNumbers { get; init; } // null = all pages
|
||||
public required (double X, double Y) Position { get; init; }
|
||||
public (double Width, double Height)? Size { get; init; }
|
||||
public Domain.Models.ValueObjects.AnnotationOrigin Origin { get; init; } = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft;
|
||||
public string? Text { get; init; }
|
||||
public string? FontName { get; init; }
|
||||
public double? FontSize { get; init; }
|
||||
public string? Color { get; init; }
|
||||
public double? Opacity { get; init; }
|
||||
public double? Rotation { get; init; }
|
||||
public Domain.Models.ValueObjects.StampPlacement Placement { get; init; } = Domain.Models.ValueObjects.StampPlacement.Foreground;
|
||||
public byte[]? ImageBytes { get; init; }
|
||||
public Domain.Models.ValueObjects.PredefinedStampType? PredefinedType { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for AddStampCommand.
|
||||
/// </summary>
|
||||
public class AddStampHandler(Common.Interfaces.IPdfProcessor pdfProcessor) : IRequestHandler<AddStampCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(AddStampCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.AddStampAsync(
|
||||
command.PdfStream,
|
||||
command.StampType,
|
||||
command.PageNumbers,
|
||||
command.Position,
|
||||
command.Size,
|
||||
command.Origin,
|
||||
command.Text,
|
||||
command.FontName,
|
||||
command.FontSize,
|
||||
command.Color,
|
||||
command.Opacity,
|
||||
command.Rotation,
|
||||
command.Placement,
|
||||
command.ImageBytes,
|
||||
command.PredefinedType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for AddStampCommand.
|
||||
/// </summary>
|
||||
public class AddStampValidator : AbstractValidator<AddStampCommand>
|
||||
{
|
||||
public AddStampValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull().WithMessage("PDF stream is required");
|
||||
|
||||
RuleFor(x => x.Position.X)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("Position X must be >= 0");
|
||||
|
||||
RuleFor(x => x.Position.Y)
|
||||
.GreaterThanOrEqualTo(0).WithMessage("Position Y must be >= 0");
|
||||
|
||||
// Text stamp validation
|
||||
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Text, () =>
|
||||
{
|
||||
RuleFor(x => x.Text)
|
||||
.NotEmpty().WithMessage("Text is required for Text stamp type");
|
||||
});
|
||||
|
||||
// Image stamp validation
|
||||
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Image, () =>
|
||||
{
|
||||
RuleFor(x => x.ImageBytes)
|
||||
.NotNull().WithMessage("Image bytes are required for Image stamp type")
|
||||
.Must(bytes => bytes != null && bytes.Length > 0).WithMessage("Image bytes cannot be empty");
|
||||
});
|
||||
|
||||
// Predefined stamp validation
|
||||
When(x => x.StampType == Domain.Models.ValueObjects.StampType.Predefined, () =>
|
||||
{
|
||||
RuleFor(x => x.PredefinedType)
|
||||
.NotNull().WithMessage("Predefined type is required for Predefined stamp type");
|
||||
});
|
||||
|
||||
// Optional parameter validations
|
||||
When(x => x.Opacity.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.Opacity!.Value)
|
||||
.InclusiveBetween(0.0, 1.0).WithMessage("Opacity must be between 0.0 and 1.0");
|
||||
});
|
||||
|
||||
When(x => x.Rotation.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.Rotation!.Value)
|
||||
.InclusiveBetween(0.0, 360.0).WithMessage("Rotation must be between 0 and 360 degrees");
|
||||
});
|
||||
|
||||
When(x => x.FontSize.HasValue, () =>
|
||||
{
|
||||
RuleFor(x => x.FontSize!.Value)
|
||||
.GreaterThan(0).WithMessage("Font size must be positive");
|
||||
});
|
||||
|
||||
When(x => !string.IsNullOrWhiteSpace(x.Color), () =>
|
||||
{
|
||||
RuleFor(x => x.Color!)
|
||||
.Matches(@"^[0-9A-Fa-f]{6}$").WithMessage("Color must be 6-digit hex (e.g., 'FF0000')");
|
||||
});
|
||||
|
||||
When(x => x.PageNumbers != null, () =>
|
||||
{
|
||||
RuleFor(x => x.PageNumbers!)
|
||||
.Must(pages => pages.All(p => p > 0)).WithMessage("All page numbers must be >= 1");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,14 @@ using MediatR;
|
||||
namespace DocumentOperator.Application.CheckPdfAttachments.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for checking PDF attachments (supports both byte array and Base64 input)
|
||||
/// Query for checking PDF attachments (Stream-based)
|
||||
/// </summary>
|
||||
public record CheckPdfAttachmentsQuery : IRequest<AttachmentCheckResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as byte array (direct upload via multipart/form-data)
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public byte[]? PdfBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PDF as Base64 string (for API clients using application/json)
|
||||
/// </summary>
|
||||
public string? Base64Pdf { get; init; }
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -33,14 +28,8 @@ public class CheckPdfAttachmentsQueryHandler(IPdfProcessor PdfProcessor, IMapper
|
||||
/// </summary>
|
||||
public async Task<AttachmentCheckResult> Handle(CheckPdfAttachmentsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Use byte[] if available, otherwise convert Base64
|
||||
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
|
||||
|
||||
// Convert to stream for IPdfProcessor
|
||||
using var pdfStream = new MemoryStream(pdfBytes);
|
||||
|
||||
// Call DevExpress service (exceptions propagate naturally)
|
||||
var attachmentInfo = await PdfProcessor.CheckAttachmentsAsync(pdfStream);
|
||||
// Call DevExpress service directly with stream (exceptions propagate naturally)
|
||||
var attachmentInfo = await PdfProcessor.CheckAttachmentsAsync(request.PdfStream);
|
||||
|
||||
// Map DTO to response DTO using AutoMapper
|
||||
return Mapper.Map<AttachmentCheckResult>(attachmentInfo);
|
||||
|
||||
@@ -4,41 +4,15 @@ namespace DocumentOperator.Application.CheckPdfAttachments.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for CheckPdfAttachmentsQuery
|
||||
/// Ensures exactly one input type (PdfBytes OR Base64Pdf) is provided
|
||||
/// Ensures PdfStream is not null
|
||||
/// </summary>
|
||||
public class CheckPdfAttachmentsQueryValidator : AbstractValidator<CheckPdfAttachmentsQuery>
|
||||
{
|
||||
public CheckPdfAttachmentsQueryValidator()
|
||||
{
|
||||
// Rule 1: Exactly ONE input must be provided (XOR logic)
|
||||
RuleFor(x => x)
|
||||
.Must(x => (x.PdfBytes != null && x.PdfBytes.Length > 0) ^
|
||||
(!string.IsNullOrWhiteSpace(x.Base64Pdf)))
|
||||
.WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both");
|
||||
|
||||
// Rule 2: Base64 format validation (if provided)
|
||||
RuleFor(x => x.Base64Pdf)
|
||||
.Must(BeValidBase64)
|
||||
.When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf))
|
||||
.WithMessage("Base64Pdf must be a valid Base64 string");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates if a string is a valid Base64 format
|
||||
/// </summary>
|
||||
private bool BeValidBase64(string? base64)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
return true; // Skip validation if null/empty (handled by Rule 1)
|
||||
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(base64);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Rule: PdfStream must be provided and non-empty
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,30 +7,142 @@ public interface IPdfProcessor
|
||||
/// <summary>
|
||||
/// Validates a PDF and extracts metadata.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>PDF metadata (page count, size, version, attachments)</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty or invalid
|
||||
/// Thrown when stream is empty, invalid, or not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<PdfMetadata> ValidateAsync(Stream pdfStream);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a PDF/A document and checks conformance level.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>PDF/A metadata including conformance level and validation errors/warnings</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty or invalid
|
||||
/// Thrown when stream is empty, invalid, or not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<PdfAMetadata> ValidatePdfAAsync(Stream pdfStream);
|
||||
|
||||
/// <summary>
|
||||
/// Checks for embedded files (attachments) in a PDF document and returns detailed metadata.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>Attachment information (count, file names, MIME types, sizes)</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty or invalid
|
||||
/// Thrown when stream is empty, invalid, or not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<AttachmentInfo> CheckAttachmentsAsync(Stream pdfStream);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all embedded files from a PDF document and returns them as a ZIP archive.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <returns>ZIP archive containing all extracted attachments as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty, invalid, or not positioned at the beginning
|
||||
/// </exception>
|
||||
/// <exception cref="Domain.Common.Exceptions.NotFoundException">
|
||||
/// Thrown when PDF contains no attachments
|
||||
/// </exception>
|
||||
Task<byte[]> ExtractAttachmentsAsync(Stream pdfStream);
|
||||
|
||||
/// <summary>
|
||||
/// Merges multiple PDF documents into a single PDF.
|
||||
/// </summary>
|
||||
/// <param name="pdfStreams">
|
||||
/// PDF streams to merge (minimum 2 required). Each stream must be readable and positioned
|
||||
/// at the beginning (Position = 0). Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="pageRanges">
|
||||
/// Optional page ranges per PDF (null = all pages). Format: "1-3,5" means pages 1, 2, 3, and 5.
|
||||
/// If null or empty for a PDF, all pages are included. Array length must match pdfStreams length if provided.
|
||||
/// </param>
|
||||
/// <returns>Merged PDF as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when fewer than 2 PDFs provided, any stream is empty/invalid/not at Position=0,
|
||||
/// or page range format is invalid
|
||||
/// </exception>
|
||||
Task<byte[]> MergePdfsAsync(IReadOnlyList<Stream> pdfStreams, IReadOnlyList<string?>? pageRanges = null);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an annotation to a PDF document at the specified page and rectangle.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="annotationType">Type of annotation to add (TextMarkup, FreeText, StickyNote, Circle, Square)</param>
|
||||
/// <param name="pageNumber">Page number (1-based) where annotation should be added</param>
|
||||
/// <param name="rectangle">Annotation bounding rectangle (X1, Y1, X2, Y2)</param>
|
||||
/// <param name="content">Annotation content/comment text (required for FreeText and StickyNote)</param>
|
||||
/// <param name="author">Optional author name</param>
|
||||
/// <param name="color">Optional annotation color in RGB format (hex string like "FF0000" for red)</param>
|
||||
/// <param name="textMarkupStyle">Text markup style (Highlight, Underline, Strikeout) - only for TextMarkup type</param>
|
||||
/// <param name="origin">Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft</param>
|
||||
/// <returns>Annotated PDF as byte array</returns>
|
||||
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
|
||||
/// Thrown when stream is empty/invalid/not at Position=0, page number out of range,
|
||||
/// rectangle invalid, or content missing for types that require it
|
||||
/// </exception>
|
||||
Task<byte[]> AddAnnotationAsync(
|
||||
Stream pdfStream,
|
||||
Domain.Models.ValueObjects.AnnotationType annotationType,
|
||||
int pageNumber,
|
||||
(double X1, double Y1, double X2, double Y2) rectangle,
|
||||
string? content = null,
|
||||
string? author = null,
|
||||
string? color = null,
|
||||
Domain.Models.ValueObjects.TextMarkupStyle? textMarkupStyle = null,
|
||||
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft);
|
||||
|
||||
/// <summary>
|
||||
/// Adds a stamp (text, image, or predefined) to specified pages of a PDF document.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">Input PDF stream (must support reading and seeking)</param>
|
||||
/// <param name="stampType">Type of stamp (Text, Image, or Predefined)</param>
|
||||
/// <param name="pageNumbers">Target page numbers (1-based). Null = all pages.</param>
|
||||
/// <param name="position">Stamp position (X, Y coordinates)</param>
|
||||
/// <param name="size">Stamp size (Width, Height). Null = auto-size for images.</param>
|
||||
/// <param name="origin">Coordinate origin (BottomLeft or TopLeft)</param>
|
||||
/// <param name="text">Text content (required for Text stamps)</param>
|
||||
/// <param name="fontName">Font name (default: Arial)</param>
|
||||
/// <param name="fontSize">Font size in points (default: 12)</param>
|
||||
/// <param name="color">Hex color without # (e.g., "FF0000" for red, default: "000000")</param>
|
||||
/// <param name="opacity">Opacity 0.0 (transparent) to 1.0 (opaque, default: 0.5)</param>
|
||||
/// <param name="rotation">Rotation angle in degrees 0-360 (default: 0)</param>
|
||||
/// <param name="placement">Foreground (on top) or Background (behind content)</param>
|
||||
/// <param name="imageBytes">Image data (required for Image stamps, PNG/JPEG)</param>
|
||||
/// <param name="predefinedType">Predefined stamp type (required for Predefined stamps)</param>
|
||||
/// <returns>Stamped PDF as byte array</returns>
|
||||
/// <exception cref="BadRequestException">Invalid parameters (missing text/image, invalid page numbers, invalid opacity/rotation)</exception>
|
||||
/// <exception cref="PdfProcessingException">DevExpress processing error</exception>
|
||||
Task<byte[]> AddStampAsync(
|
||||
Stream pdfStream,
|
||||
Domain.Models.ValueObjects.StampType stampType,
|
||||
int[]? pageNumbers,
|
||||
(double X, double Y) position,
|
||||
(double Width, double Height)? size = null,
|
||||
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft,
|
||||
string? text = null,
|
||||
string? fontName = null,
|
||||
double? fontSize = null,
|
||||
string? color = null,
|
||||
double? opacity = null,
|
||||
double? rotation = null,
|
||||
Domain.Models.ValueObjects.StampPlacement placement = Domain.Models.ValueObjects.StampPlacement.Foreground,
|
||||
byte[]? imageBytes = null,
|
||||
Domain.Models.ValueObjects.PredefinedStampType? predefinedType = null);
|
||||
}
|
||||
@@ -12,18 +12,21 @@ public interface ISwissQrCodeProcessor
|
||||
/// Extracts and parses Swiss QR Code from a PDF document.
|
||||
/// Returns both parsed Bill object and raw QR text lines.
|
||||
/// </summary>
|
||||
/// <param name="pdfBytes">PDF document as byte array</param>
|
||||
/// <param name="pdfStream">
|
||||
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
|
||||
/// Non-seekable streams are supported. Caller is responsible for disposal.
|
||||
/// </param>
|
||||
/// <param name="pageNumbers">Optional: Specific page numbers to scan (1-indexed). If null, scans all pages starting with last page.</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Tuple: (Parsed Codecrete Bill, Raw QR lines as string array)</returns>
|
||||
/// <exception cref="Domain.Exceptions.SwissQrCodeNotFoundException">
|
||||
/// <exception cref="Domain.Common.Exceptions.NotFoundException">
|
||||
/// Thrown when no Swiss QR Code is found in the specified pages
|
||||
/// </exception>
|
||||
/// <exception cref="Domain.Exceptions.PdfProcessingException">
|
||||
/// Thrown when PDF processing fails
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when stream is empty or (for seekable streams) not positioned at the beginning
|
||||
/// </exception>
|
||||
Task<(Bill Bill, string[] RawLines)> ExtractSwissQrCodeAsync(
|
||||
byte[] pdfBytes,
|
||||
Stream pdfStream,
|
||||
int[]? pageNumbers = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentOperator.Application.ExtractPdfAttachments;
|
||||
|
||||
/// <summary>
|
||||
/// Command to extract all embedded files from a PDF document and return as ZIP archive.
|
||||
/// </summary>
|
||||
public record ExtractPdfAttachmentsCommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF document stream. Must be positioned at the beginning (Position = 0).
|
||||
/// </summary>
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for ExtractPdfAttachmentsCommand.
|
||||
/// Extracts all embedded files from PDF and returns as ZIP archive.
|
||||
/// </summary>
|
||||
public class ExtractPdfAttachmentsCommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<ExtractPdfAttachmentsCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(ExtractPdfAttachmentsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Delegate to infrastructure layer
|
||||
return await pdfProcessor.ExtractAttachmentsAsync(request.PdfStream);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ExtractPdfAttachmentsCommand.
|
||||
/// </summary>
|
||||
public class ExtractPdfAttachmentsCommandValidator : AbstractValidator<ExtractPdfAttachmentsCommand>
|
||||
{
|
||||
public ExtractPdfAttachmentsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PDF stream is required");
|
||||
}
|
||||
}
|
||||
53
DocumentOperator.Application/MergePdfs/MergePdfsCommand.cs
Normal file
53
DocumentOperator.Application/MergePdfs/MergePdfsCommand.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace DocumentOperator.Application.MergePdfs;
|
||||
|
||||
/// <summary>
|
||||
/// Command to merge multiple PDF documents into a single PDF.
|
||||
/// </summary>
|
||||
public record MergePdfsCommand : IRequest<byte[]>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF streams to merge. Minimum 2 required. Each must be at Position=0.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<Stream> PdfStreams { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional page ranges per PDF (null = all pages).
|
||||
/// Format: "1-3,5" means pages 1, 2, 3, and 5.
|
||||
/// If provided, array length must match PdfStreams length.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string?>? PageRanges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for MergePdfsCommand.
|
||||
/// Delegates PDF merge operation to infrastructure layer.
|
||||
/// </summary>
|
||||
public class MergePdfsCommandHandler(IPdfProcessor pdfProcessor)
|
||||
: IRequestHandler<MergePdfsCommand, byte[]>
|
||||
{
|
||||
public async Task<byte[]> Handle(MergePdfsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
return await pdfProcessor.MergePdfsAsync(request.PdfStreams, request.PageRanges);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator for MergePdfsCommand.
|
||||
/// </summary>
|
||||
public class MergePdfsCommandValidator : AbstractValidator<MergePdfsCommand>
|
||||
{
|
||||
public MergePdfsCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.PdfStreams)
|
||||
.NotNull()
|
||||
.WithMessage("PDF streams are required");
|
||||
|
||||
RuleFor(x => x.PdfStreams)
|
||||
.Must(streams => streams != null && streams.Count >= 2)
|
||||
.WithMessage("At least 2 PDF files are required for merging");
|
||||
}
|
||||
}
|
||||
@@ -6,19 +6,14 @@ using MediatR;
|
||||
namespace DocumentOperator.Application.SwissQrCode.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for extracting Swiss QR Code from PDF (supports both byte array and Base64 input)
|
||||
/// Query for extracting Swiss QR Code from PDF (Stream-based)
|
||||
/// </summary>
|
||||
public record ExtractSwissQrCodeQuery : IRequest<SwissQrCodeExtractionResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as byte array (direct upload)
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public byte[]? PdfBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PDF as Base64 string (API clients)
|
||||
/// </summary>
|
||||
public string? Base64Pdf { get; init; }
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -34,11 +29,8 @@ public class ExtractSwissQrCodeQueryHandler(ISwissQrCodeProcessor qrCodeProcesso
|
||||
/// </summary>
|
||||
public async Task<SwissQrCodeExtractionResult> Handle(ExtractSwissQrCodeQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Use byte[] if available, otherwise convert Base64
|
||||
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
|
||||
|
||||
// Extract: returns (Bill, RawLines)
|
||||
var (bill, rawLines) = await qrCodeProcessor.ExtractSwissQrCodeAsync(pdfBytes, pageNumbers: null, cancellationToken);
|
||||
// Extract: returns (Bill, RawLines) - pass stream directly
|
||||
var (bill, rawLines) = await qrCodeProcessor.ExtractSwissQrCodeAsync(request.PdfStream, pageNumbers: null, cancellationToken);
|
||||
|
||||
// Map Codecrete Bill to DTO using AutoMapper
|
||||
var billDto = mapper.Map<SwissQrBillDto>(bill);
|
||||
|
||||
@@ -5,55 +5,15 @@ namespace DocumentOperator.Application.SwissQrCode.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Validates ExtractSwissQrCodeQuery before handler execution.
|
||||
/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none).
|
||||
/// Ensures PdfStream is not null.
|
||||
/// </summary>
|
||||
public sealed class ExtractSwissQrCodeQueryValidator : AbstractValidator<ExtractSwissQrCodeQuery>
|
||||
{
|
||||
public ExtractSwissQrCodeQueryValidator()
|
||||
{
|
||||
RuleFor(x => x)
|
||||
.Must(HasExactlyOneInput)
|
||||
.WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both");
|
||||
|
||||
// Validate Base64 format if provided
|
||||
When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf), () =>
|
||||
{
|
||||
RuleFor(x => x.Base64Pdf)
|
||||
.Must(BeValidBase64)
|
||||
.WithMessage("Invalid Base64 format");
|
||||
});
|
||||
|
||||
// Validate byte array if provided
|
||||
When(x => x.PdfBytes != null, () =>
|
||||
{
|
||||
RuleFor(x => x.PdfBytes)
|
||||
.NotEmpty()
|
||||
.WithMessage("PdfBytes cannot be empty");
|
||||
});
|
||||
}
|
||||
|
||||
private static bool HasExactlyOneInput(ExtractSwissQrCodeQuery request)
|
||||
{
|
||||
var hasPdfBytes = request.PdfBytes != null && request.PdfBytes.Length > 0;
|
||||
var hasBase64 = !string.IsNullOrWhiteSpace(request.Base64Pdf);
|
||||
|
||||
// XOR: exactly one must be true
|
||||
return hasPdfBytes ^ hasBase64;
|
||||
}
|
||||
|
||||
private static bool BeValidBase64(string? base64)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(base64);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Rule: PdfStream must be provided
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,14 @@ using MediatR;
|
||||
namespace DocumentOperator.Application.ValidatePdf.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for PDF validation (supports both byte array and Base64 input)
|
||||
/// Query for PDF validation (Stream-based)
|
||||
/// </summary>
|
||||
public record ValidatePdfQuery : IRequest<PdfValidationResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as byte array (direct upload)
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public byte[]? PdfBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PDF as Base64 string (API clients)
|
||||
/// </summary>
|
||||
public string? Base64Pdf { get; init; }
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -33,14 +28,8 @@ public class ValidatePdfQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper)
|
||||
/// </summary>
|
||||
public async Task<PdfValidationResult> Handle(ValidatePdfQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Use byte[] if available, otherwise convert Base64
|
||||
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
|
||||
|
||||
// Convert to stream for IPdfProcessor (using MemoryStream)
|
||||
using var pdfStream = new MemoryStream(pdfBytes);
|
||||
|
||||
// Call DevExpress service (exceptions propagate naturally)
|
||||
var metadata = await PdfProcessor.ValidateAsync(pdfStream);
|
||||
// Call DevExpress service directly with stream (exceptions propagate naturally)
|
||||
var metadata = await PdfProcessor.ValidateAsync(request.PdfStream);
|
||||
|
||||
// Map DTO to response DTO using AutoMapper
|
||||
return Mapper.Map<PdfValidationResult>(metadata);
|
||||
|
||||
@@ -5,55 +5,15 @@ namespace DocumentOperator.Application.ValidatePdf.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ValidatePdfQuery
|
||||
/// Ensures exactly ONE input method is provided (either PdfBytes OR Base64Pdf, not both, not none)
|
||||
/// Ensures PdfStream is not null
|
||||
/// </summary>
|
||||
public class ValidatePdfQueryValidator : AbstractValidator<ValidatePdfQuery>
|
||||
{
|
||||
public ValidatePdfQueryValidator()
|
||||
{
|
||||
RuleFor(x => x)
|
||||
.Must(HasExactlyOneInput)
|
||||
.WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both");
|
||||
|
||||
// Validate Base64 format if provided
|
||||
When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf), () =>
|
||||
{
|
||||
RuleFor(x => x.Base64Pdf)
|
||||
.Must(BeValidBase64)
|
||||
.WithMessage("Invalid Base64 format");
|
||||
});
|
||||
|
||||
// Validate byte array if provided
|
||||
When(x => x.PdfBytes != null, () =>
|
||||
{
|
||||
RuleFor(x => x.PdfBytes)
|
||||
.NotEmpty()
|
||||
.WithMessage("PdfBytes cannot be empty");
|
||||
});
|
||||
}
|
||||
|
||||
private static bool HasExactlyOneInput(ValidatePdfQuery request)
|
||||
{
|
||||
var hasPdfBytes = request.PdfBytes != null && request.PdfBytes.Length > 0;
|
||||
var hasBase64 = !string.IsNullOrWhiteSpace(request.Base64Pdf);
|
||||
|
||||
// XOR: exactly one must be true
|
||||
return hasPdfBytes ^ hasBase64;
|
||||
}
|
||||
|
||||
private static bool BeValidBase64(string? base64)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(base64);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Rule: PdfStream must be provided
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,14 @@ using MediatR;
|
||||
namespace DocumentOperator.Application.ValidatePdfA.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Query for PDF/A validation (supports both byte array and Base64 input)
|
||||
/// Query for PDF/A validation (Stream-based)
|
||||
/// </summary>
|
||||
public record ValidatePdfAQuery : IRequest<PdfAValidationResult>
|
||||
{
|
||||
/// <summary>
|
||||
/// PDF as byte array (direct upload)
|
||||
/// PDF as stream (caller is responsible for disposal)
|
||||
/// </summary>
|
||||
public byte[]? PdfBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// PDF as Base64 string (API clients)
|
||||
/// </summary>
|
||||
public string? Base64Pdf { get; init; }
|
||||
public required Stream PdfStream { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -33,14 +28,8 @@ public class ValidatePdfAQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper
|
||||
/// </summary>
|
||||
public async Task<PdfAValidationResult> Handle(ValidatePdfAQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Use byte[] if available, otherwise convert Base64
|
||||
byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!);
|
||||
|
||||
// Convert to stream for IPdfProcessor
|
||||
using var pdfStream = new MemoryStream(pdfBytes);
|
||||
|
||||
// Call DevExpress service (exceptions propagate naturally)
|
||||
var metadata = await PdfProcessor.ValidatePdfAAsync(pdfStream);
|
||||
// Call DevExpress service directly with stream (exceptions propagate naturally)
|
||||
var metadata = await PdfProcessor.ValidatePdfAAsync(request.PdfStream);
|
||||
|
||||
// Map DTO to response DTO using AutoMapper
|
||||
return Mapper.Map<PdfAValidationResult>(metadata);
|
||||
|
||||
@@ -4,45 +4,15 @@ namespace DocumentOperator.Application.ValidatePdfA.Validators;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for ValidatePdfAQuery
|
||||
/// Ensures exactly ONE input format is provided (PdfBytes XOR Base64Pdf)
|
||||
/// Ensures PdfStream is not null
|
||||
/// </summary>
|
||||
public class ValidatePdfAQueryValidator : AbstractValidator<Queries.ValidatePdfAQuery>
|
||||
{
|
||||
public ValidatePdfAQueryValidator()
|
||||
{
|
||||
RuleFor(x => x)
|
||||
.Must(x => (x.PdfBytes != null && x.PdfBytes.Length > 0) ^
|
||||
!string.IsNullOrWhiteSpace(x.Base64Pdf))
|
||||
.WithMessage("Either PdfBytes or Base64Pdf must be provided, but not both");
|
||||
|
||||
When(x => !string.IsNullOrWhiteSpace(x.Base64Pdf), () =>
|
||||
{
|
||||
RuleFor(x => x.Base64Pdf!)
|
||||
.Must(BeValidBase64)
|
||||
.WithMessage("Base64Pdf must be a valid Base64 string");
|
||||
});
|
||||
|
||||
When(x => x.PdfBytes != null, () =>
|
||||
{
|
||||
RuleFor(x => x.PdfBytes!)
|
||||
.Must(bytes => bytes.Length > 0)
|
||||
.WithMessage("PdfBytes cannot be empty");
|
||||
});
|
||||
}
|
||||
|
||||
private static bool BeValidBase64(string base64)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(base64);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Rule: PdfStream must be provided
|
||||
RuleFor(x => x.PdfStream)
|
||||
.NotNull()
|
||||
.WithMessage("PdfStream is required");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
namespace DocumentOperator.Domain.Common.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when PDF processing operations fail.
|
||||
/// Maps to HTTP 500 Internal Server Error or 422 Unprocessable Entity in the API layer.
|
||||
/// </summary>
|
||||
[Obsolete("This exception is deprecated. Use more specific exceptions for PDF processing errors.")]
|
||||
public class PdfProcessingException : DomainException
|
||||
{
|
||||
public string Operation { get; }
|
||||
|
||||
public PdfProcessingException(string operation, string message)
|
||||
: base($"PDF processing failed during '{operation}': {message}", "PDF_PROCESSING_ERROR")
|
||||
{
|
||||
Operation = operation;
|
||||
}
|
||||
|
||||
public PdfProcessingException(string operation, string message, Exception innerException)
|
||||
: base($"PDF processing failed during '{operation}': {message}", "PDF_PROCESSING_ERROR", innerException)
|
||||
{
|
||||
Operation = operation;
|
||||
}
|
||||
|
||||
public PdfProcessingException(string message)
|
||||
: base(message, "PDF_PROCESSING_ERROR")
|
||||
{
|
||||
Operation = "Unknown";
|
||||
}
|
||||
|
||||
public PdfProcessingException(string message, Exception innerException)
|
||||
: base(message, "PDF_PROCESSING_ERROR", innerException)
|
||||
{
|
||||
Operation = "Unknown";
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
namespace DocumentOperator.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// Exception thrown when a Swiss QR Code cannot be found in a PDF document.
|
||||
/// </summary>
|
||||
[Obsolete("This exception is deprecated. Use SwissQrCodeNotFoundException instead.")]
|
||||
public sealed class SwissQrCodeNotFoundException : Exception
|
||||
{
|
||||
public SwissQrCodeNotFoundException()
|
||||
: base("No Swiss QR Code found on the last page of the PDF document.")
|
||||
{
|
||||
}
|
||||
|
||||
public SwissQrCodeNotFoundException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public SwissQrCodeNotFoundException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace DocumentOperator.Domain.Models.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Coordinate origin point for PDF annotations.
|
||||
/// PDF default is BottomLeft, but users may prefer TopLeft for easier UI integration.
|
||||
/// </summary>
|
||||
public enum AnnotationOrigin
|
||||
{
|
||||
/// <summary>
|
||||
/// Bottom-left corner (PDF native coordinate system, default)
|
||||
/// </summary>
|
||||
BottomLeft,
|
||||
|
||||
/// <summary>
|
||||
/// Top-left corner (common in UI frameworks)
|
||||
/// </summary>
|
||||
TopLeft
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace DocumentOperator.Domain.Models.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Supported PDF annotation types
|
||||
/// </summary>
|
||||
public enum AnnotationType
|
||||
{
|
||||
/// <summary>
|
||||
/// Text markup annotation (highlight, underline, strikeout)
|
||||
/// </summary>
|
||||
TextMarkup,
|
||||
|
||||
/// <summary>
|
||||
/// Free text annotation (text box with visible text)
|
||||
/// </summary>
|
||||
FreeText,
|
||||
|
||||
/// <summary>
|
||||
/// Sticky note annotation (popup comment icon)
|
||||
/// </summary>
|
||||
StickyNote,
|
||||
|
||||
/// <summary>
|
||||
/// Circle shape annotation
|
||||
/// </summary>
|
||||
Circle,
|
||||
|
||||
/// <summary>
|
||||
/// Square shape annotation
|
||||
/// </summary>
|
||||
Square
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace DocumentOperator.Domain.Models.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Predefined stamp types with standard text and styling.
|
||||
/// </summary>
|
||||
public enum PredefinedStampType
|
||||
{
|
||||
/// <summary>
|
||||
/// CONFIDENTIAL stamp (red, bold).
|
||||
/// </summary>
|
||||
Confidential,
|
||||
|
||||
/// <summary>
|
||||
/// APPROVED stamp (green, bold).
|
||||
/// </summary>
|
||||
Approved,
|
||||
|
||||
/// <summary>
|
||||
/// DRAFT stamp (gray, italic).
|
||||
/// </summary>
|
||||
Draft,
|
||||
|
||||
/// <summary>
|
||||
/// VOID stamp (red, strikethrough effect).
|
||||
/// </summary>
|
||||
Void,
|
||||
|
||||
/// <summary>
|
||||
/// FOR REVIEW stamp (orange, bold).
|
||||
/// </summary>
|
||||
ForReview
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace DocumentOperator.Domain.Models.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies whether the stamp should appear in the foreground (on top of content) or background (behind content).
|
||||
/// </summary>
|
||||
public enum StampPlacement
|
||||
{
|
||||
/// <summary>
|
||||
/// Stamp appears on top of existing page content.
|
||||
/// </summary>
|
||||
Foreground,
|
||||
|
||||
/// <summary>
|
||||
/// Stamp appears behind existing page content (watermark effect).
|
||||
/// </summary>
|
||||
Background
|
||||
}
|
||||
22
DocumentOperator.Domain/Models/ValueObjects/StampType.cs
Normal file
22
DocumentOperator.Domain/Models/ValueObjects/StampType.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace DocumentOperator.Domain.Models.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the type of stamp to add to a PDF document.
|
||||
/// </summary>
|
||||
public enum StampType
|
||||
{
|
||||
/// <summary>
|
||||
/// Text-based stamp with custom text, font, and color.
|
||||
/// </summary>
|
||||
Text,
|
||||
|
||||
/// <summary>
|
||||
/// Image-based stamp (PNG/JPEG overlay).
|
||||
/// </summary>
|
||||
Image,
|
||||
|
||||
/// <summary>
|
||||
/// Predefined stamp with standard text (e.g., CONFIDENTIAL, APPROVED).
|
||||
/// </summary>
|
||||
Predefined
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace DocumentOperator.Domain.Models.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Text markup annotation style (highlight, underline, strikeout)
|
||||
/// Maps to DevExpress PdfTextMarkupAnnotationType
|
||||
/// </summary>
|
||||
public enum TextMarkupStyle
|
||||
{
|
||||
/// <summary>
|
||||
/// Highlight text with background color
|
||||
/// </summary>
|
||||
Highlight,
|
||||
|
||||
/// <summary>
|
||||
/// Underline text
|
||||
/// </summary>
|
||||
Underline,
|
||||
|
||||
/// <summary>
|
||||
/// Strikeout text (strikethrough)
|
||||
/// </summary>
|
||||
Strikeout
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using DevExpress.Pdf;
|
||||
using DevExpress.Drawing;
|
||||
using System.Drawing;
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using DocumentOperator.Domain.Common.Exceptions;
|
||||
@@ -28,6 +30,12 @@ public class DevExpressPdfProcessor : IPdfProcessor
|
||||
throw new BadRequestException("PDF stream cannot be empty");
|
||||
}
|
||||
|
||||
// Defensive validation: Seekable streams must be at Position = 0
|
||||
if (pdfStream.CanSeek && pdfStream.Position != 0)
|
||||
{
|
||||
throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0).");
|
||||
}
|
||||
|
||||
// 2. Read stream to byte array for raw data analysis
|
||||
// (DevExpress needs byte[] for some operations like attachment detection)
|
||||
byte[] pdfBytes;
|
||||
@@ -39,14 +47,18 @@ public class DevExpressPdfProcessor : IPdfProcessor
|
||||
else
|
||||
{
|
||||
// Slow path: copy stream to byte array
|
||||
pdfStream.Position = 0;
|
||||
using var memoryStream = new MemoryStream();
|
||||
await pdfStream.CopyToAsync(memoryStream);
|
||||
pdfBytes = memoryStream.ToArray();
|
||||
}
|
||||
|
||||
// 3. Load PDF with DevExpress Document API
|
||||
pdfStream.Position = 0;
|
||||
// Reset position for DevExpress (seekable streams only)
|
||||
if (pdfStream.CanSeek)
|
||||
{
|
||||
pdfStream.Position = 0;
|
||||
}
|
||||
|
||||
using var processor = new PdfDocumentProcessor();
|
||||
processor.LoadDocument(pdfStream);
|
||||
|
||||
@@ -87,6 +99,12 @@ public class DevExpressPdfProcessor : IPdfProcessor
|
||||
throw new BadRequestException("PDF stream cannot be empty");
|
||||
}
|
||||
|
||||
// Defensive validation: Seekable streams must be at Position = 0
|
||||
if (pdfStream.CanSeek && pdfStream.Position != 0)
|
||||
{
|
||||
throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0).");
|
||||
}
|
||||
|
||||
// 2. Read stream to byte array for raw data analysis
|
||||
byte[] pdfBytes;
|
||||
if (pdfStream is MemoryStream ms && ms.TryGetBuffer(out var buffer))
|
||||
@@ -95,14 +113,18 @@ public class DevExpressPdfProcessor : IPdfProcessor
|
||||
}
|
||||
else
|
||||
{
|
||||
pdfStream.Position = 0;
|
||||
using var memoryStream = new MemoryStream();
|
||||
await pdfStream.CopyToAsync(memoryStream);
|
||||
pdfBytes = memoryStream.ToArray();
|
||||
}
|
||||
|
||||
// 3. Load PDF with DevExpress Document API
|
||||
pdfStream.Position = 0;
|
||||
// Reset position for DevExpress (seekable streams only)
|
||||
if (pdfStream.CanSeek)
|
||||
{
|
||||
pdfStream.Position = 0;
|
||||
}
|
||||
|
||||
using var processor = new PdfDocumentProcessor();
|
||||
processor.LoadDocument(pdfStream);
|
||||
|
||||
@@ -175,8 +197,15 @@ public class DevExpressPdfProcessor : IPdfProcessor
|
||||
throw new BadRequestException("PDF stream cannot be empty");
|
||||
}
|
||||
|
||||
// 2. Load PDF with DevExpress Document API (exceptions propagate naturally)
|
||||
pdfStream.Position = 0;
|
||||
// Defensive validation: Seekable streams must be at Position = 0
|
||||
if (pdfStream.CanSeek && pdfStream.Position != 0)
|
||||
{
|
||||
throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0).");
|
||||
}
|
||||
|
||||
// 2. Load PDF with DevExpress Document API
|
||||
// DevExpress LoadDocument may throw exceptions for corrupted PDFs - let them propagate naturally
|
||||
// Middleware will catch and convert to 500 Internal Server Error
|
||||
using var processor = new PdfDocumentProcessor();
|
||||
processor.LoadDocument(pdfStream);
|
||||
|
||||
@@ -207,6 +236,420 @@ public class DevExpressPdfProcessor : IPdfProcessor
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts all embedded files from a PDF document and returns them as a ZIP archive.
|
||||
/// Uses DevExpress PdfDocument.FileAttachments to retrieve attachment data.
|
||||
/// </summary>
|
||||
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
|
||||
/// <returns>ZIP archive byte array containing all extracted attachments</returns>
|
||||
/// <exception cref="BadRequestException">Thrown when stream is empty</exception>
|
||||
/// <exception cref="NotFoundException">Thrown when PDF contains no attachments</exception>
|
||||
public async Task<byte[]> ExtractAttachmentsAsync(Stream pdfStream)
|
||||
{
|
||||
// 1. Input Validation
|
||||
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
|
||||
|
||||
if (pdfStream.Length == 0)
|
||||
{
|
||||
throw new BadRequestException("PDF stream cannot be empty");
|
||||
}
|
||||
|
||||
// Defensive validation: Seekable streams must be at Position = 0
|
||||
if (pdfStream.CanSeek && pdfStream.Position != 0)
|
||||
{
|
||||
throw new BadRequestException("PDF stream must be positioned at the beginning (Position = 0).");
|
||||
}
|
||||
|
||||
// 2. Load PDF with DevExpress Document API
|
||||
using var processor = new PdfDocumentProcessor();
|
||||
processor.LoadDocument(pdfStream);
|
||||
|
||||
var document = processor.Document;
|
||||
|
||||
// 3. Extract attachment data using DevExpress FileAttachments collection
|
||||
var fileAttachments = document.FileAttachments;
|
||||
|
||||
// 4. No attachments case
|
||||
if (fileAttachments == null || !fileAttachments.Any())
|
||||
{
|
||||
throw new NotFoundException("PDF does not contain any attachments");
|
||||
}
|
||||
|
||||
// 5. Create ZIP archive in memory
|
||||
using var zipStream = new MemoryStream();
|
||||
using (var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var attachment in fileAttachments)
|
||||
{
|
||||
// Get attachment metadata
|
||||
string fileName = attachment.FileName ?? "unnamed";
|
||||
byte[] fileData = attachment.Data;
|
||||
|
||||
// Create entry in ZIP
|
||||
var entry = zipArchive.CreateEntry(fileName, System.IO.Compression.CompressionLevel.Optimal);
|
||||
|
||||
// Write attachment data to ZIP entry
|
||||
using var entryStream = entry.Open();
|
||||
await entryStream.WriteAsync(fileData, 0, fileData.Length);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Return ZIP byte array
|
||||
return zipStream.ToArray();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PDF Merge Operations
|
||||
|
||||
public async Task<byte[]> MergePdfsAsync(IReadOnlyList<Stream> pdfStreams, IReadOnlyList<string?>? pageRanges = null)
|
||||
{
|
||||
// 1. Validate input: minimum 2 PDFs required
|
||||
if (pdfStreams == null || pdfStreams.Count < 2)
|
||||
throw new BadRequestException("At least 2 PDF files are required for merging");
|
||||
|
||||
// 2. Validate page ranges length (if provided)
|
||||
if (pageRanges != null && pageRanges.Count != pdfStreams.Count)
|
||||
throw new BadRequestException($"Page ranges count ({pageRanges.Count}) must match PDF files count ({pdfStreams.Count})");
|
||||
|
||||
// 3. Defensive validation: all streams must be at Position = 0
|
||||
for (int i = 0; i < pdfStreams.Count; i++)
|
||||
{
|
||||
var stream = pdfStreams[i];
|
||||
|
||||
if (stream == null)
|
||||
throw new BadRequestException($"PDF stream at index {i} is null");
|
||||
|
||||
if (stream.Length == 0)
|
||||
throw new BadRequestException($"PDF stream at index {i} is empty");
|
||||
|
||||
if (stream.CanSeek && stream.Position != 0)
|
||||
throw new BadRequestException(null, new ArgumentException(
|
||||
$"PDF stream at index {i} must be positioned at the beginning (Position = 0).",
|
||||
nameof(pdfStreams)));
|
||||
}
|
||||
|
||||
// 4. Create merged PDF using DevExpress
|
||||
using var mergedProcessor = new PdfDocumentProcessor();
|
||||
|
||||
// Load first PDF as base document
|
||||
mergedProcessor.LoadDocument(pdfStreams[0]);
|
||||
|
||||
// Apply page range to first PDF if specified
|
||||
if (pageRanges != null && !string.IsNullOrWhiteSpace(pageRanges[0]))
|
||||
{
|
||||
var pageIndices = ParsePageRange(pageRanges[0]!, mergedProcessor.Document.Pages.Count);
|
||||
// Remove pages not in range (process in reverse to maintain indices)
|
||||
for (int i = mergedProcessor.Document.Pages.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!pageIndices.Contains(i))
|
||||
mergedProcessor.Document.Pages.RemoveAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Append remaining PDFs
|
||||
for (int i = 1; i < pdfStreams.Count; i++)
|
||||
{
|
||||
string? pageRange = pageRanges?[i];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(pageRange))
|
||||
{
|
||||
// Append all pages
|
||||
mergedProcessor.AppendDocument(pdfStreams[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Parse page range and append selected pages
|
||||
// Note: We need to load the document first to validate page range
|
||||
using var tempProcessor = new PdfDocumentProcessor();
|
||||
tempProcessor.LoadDocument(pdfStreams[i]);
|
||||
|
||||
var pageIndices = ParsePageRange(pageRange, tempProcessor.Document.Pages.Count);
|
||||
|
||||
// DevExpress AppendDocument doesn't support arbitrary page selection
|
||||
// Workaround: Create temp PDF with selected pages, then append
|
||||
using var tempStream = new MemoryStream();
|
||||
|
||||
// Remove unwanted pages from temp document (in reverse order)
|
||||
for (int j = tempProcessor.Document.Pages.Count - 1; j >= 0; j--)
|
||||
{
|
||||
if (!pageIndices.Contains(j))
|
||||
tempProcessor.Document.Pages.RemoveAt(j);
|
||||
}
|
||||
|
||||
tempProcessor.SaveDocument(tempStream);
|
||||
tempStream.Position = 0;
|
||||
mergedProcessor.AppendDocument(tempStream);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Save merged PDF to byte array
|
||||
using var outputStream = new MemoryStream();
|
||||
mergedProcessor.SaveDocument(outputStream);
|
||||
|
||||
return await Task.FromResult(outputStream.ToArray());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region PDF Annotation
|
||||
|
||||
/// <summary>
|
||||
/// Adds an annotation to a PDF document at the specified page and rectangle.
|
||||
/// </summary>
|
||||
public async Task<byte[]> AddAnnotationAsync(
|
||||
Stream pdfStream,
|
||||
Domain.Models.ValueObjects.AnnotationType annotationType,
|
||||
int pageNumber,
|
||||
(double X1, double Y1, double X2, double Y2) rectangle,
|
||||
string? content = null,
|
||||
string? author = null,
|
||||
string? color = null,
|
||||
Domain.Models.ValueObjects.TextMarkupStyle? textMarkupStyle = null,
|
||||
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft)
|
||||
{
|
||||
// 1. Input validation
|
||||
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
|
||||
|
||||
if (pdfStream.Length == 0)
|
||||
throw new BadRequestException("PDF stream cannot be empty");
|
||||
|
||||
if (pdfStream.Position != 0)
|
||||
throw new BadRequestException($"PDF stream must be at position 0 (current position: {pdfStream.Position})");
|
||||
|
||||
if (pageNumber < 1)
|
||||
throw new BadRequestException($"Page number must be >= 1 (provided: {pageNumber})");
|
||||
|
||||
// Validate content requirement
|
||||
if (annotationType is Domain.Models.ValueObjects.AnnotationType.FreeText
|
||||
or Domain.Models.ValueObjects.AnnotationType.StickyNote)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
throw new BadRequestException($"{annotationType} annotation requires content");
|
||||
}
|
||||
|
||||
// Validate TextMarkup style requirement
|
||||
if (annotationType == Domain.Models.ValueObjects.AnnotationType.TextMarkup && textMarkupStyle == null)
|
||||
throw new BadRequestException("TextMarkup annotation requires textMarkupStyle parameter");
|
||||
|
||||
byte[] annotatedPdfBytes;
|
||||
|
||||
try
|
||||
{
|
||||
// 2. Load PDF
|
||||
using var processor = new PdfDocumentProcessor();
|
||||
processor.LoadDocument(pdfStream);
|
||||
|
||||
// 3. Validate page number
|
||||
int pageCount = processor.Document.Pages.Count;
|
||||
if (pageNumber > pageCount)
|
||||
throw new BadRequestException($"Page number {pageNumber} exceeds document page count ({pageCount})");
|
||||
|
||||
// 4. Convert coordinates if origin is TopLeft
|
||||
var pdfRectangle = rectangle;
|
||||
if (origin == Domain.Models.ValueObjects.AnnotationOrigin.TopLeft)
|
||||
{
|
||||
var page = processor.Document.Pages[pageNumber - 1];
|
||||
double pageHeight = page.CropBox.Height;
|
||||
|
||||
// Convert Y coordinates: TopLeft → BottomLeft
|
||||
// TopLeft Y=0 → BottomLeft Y=pageHeight
|
||||
// TopLeft Y=pageHeight → BottomLeft Y=0
|
||||
pdfRectangle = (
|
||||
rectangle.X1,
|
||||
pageHeight - rectangle.Y2, // Y2 becomes Y1 (top → bottom)
|
||||
rectangle.X2,
|
||||
pageHeight - rectangle.Y1 // Y1 becomes Y2 (bottom → top)
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Get page facade (zero-based index)
|
||||
var pageFacade = processor.DocumentFacade.Pages[pageNumber - 1];
|
||||
|
||||
// 6. Create annotation rectangle from converted coordinates
|
||||
var pdfRect = new PdfRectangle(pdfRectangle.X1, pdfRectangle.Y1, pdfRectangle.X2, pdfRectangle.Y2);
|
||||
|
||||
// 7. Parse color (default to yellow for highlights, red for others)
|
||||
PdfRGBColor annotationColor = ParseColor(color) ?? (annotationType == Domain.Models.ValueObjects.AnnotationType.TextMarkup
|
||||
? new PdfRGBColor(1.0, 1.0, 0) // Yellow
|
||||
: new PdfRGBColor(1.0, 0, 0)); // Red
|
||||
|
||||
// 8. Add annotation based on type
|
||||
switch (annotationType)
|
||||
{
|
||||
case Domain.Models.ValueObjects.AnnotationType.TextMarkup:
|
||||
AddTextMarkupAnnotation(pageFacade, pdfRect, textMarkupStyle!.Value, content, author, annotationColor);
|
||||
break;
|
||||
|
||||
case Domain.Models.ValueObjects.AnnotationType.FreeText:
|
||||
AddFreeTextAnnotation(pageFacade, pdfRect, content!, author, annotationColor);
|
||||
break;
|
||||
|
||||
case Domain.Models.ValueObjects.AnnotationType.StickyNote:
|
||||
AddStickyNoteAnnotation(pageFacade, pdfRect, content!, author, annotationColor);
|
||||
break;
|
||||
|
||||
case Domain.Models.ValueObjects.AnnotationType.Circle:
|
||||
AddCircleAnnotation(pageFacade, pdfRect, content, author, annotationColor);
|
||||
break;
|
||||
|
||||
case Domain.Models.ValueObjects.AnnotationType.Square:
|
||||
AddSquareAnnotation(pageFacade, pdfRect, content, author, annotationColor);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new BadRequestException($"Unsupported annotation type: {annotationType}");
|
||||
}
|
||||
|
||||
// 8. Save annotated PDF
|
||||
using var outputStream = new MemoryStream();
|
||||
processor.SaveDocument(outputStream);
|
||||
annotatedPdfBytes = outputStream.ToArray();
|
||||
}
|
||||
catch (BadRequestException)
|
||||
{
|
||||
throw; // Re-throw our own exceptions
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new BadRequestException($"Failed to add annotation: {ex.Message}");
|
||||
}
|
||||
|
||||
return await Task.FromResult(annotatedPdfBytes);
|
||||
}
|
||||
|
||||
private void AddTextMarkupAnnotation(
|
||||
PdfPageFacade pageFacade,
|
||||
PdfRectangle rectangle,
|
||||
Domain.Models.ValueObjects.TextMarkupStyle style,
|
||||
string? content,
|
||||
string? author,
|
||||
PdfRGBColor color)
|
||||
{
|
||||
// Map our enum to DevExpress enum
|
||||
var devExpressStyle = style switch
|
||||
{
|
||||
Domain.Models.ValueObjects.TextMarkupStyle.Highlight => PdfTextMarkupAnnotationType.Highlight,
|
||||
Domain.Models.ValueObjects.TextMarkupStyle.Underline => PdfTextMarkupAnnotationType.Underline,
|
||||
Domain.Models.ValueObjects.TextMarkupStyle.Strikeout => PdfTextMarkupAnnotationType.StrikeOut,
|
||||
_ => throw new BadRequestException($"Unsupported text markup style: {style}")
|
||||
};
|
||||
|
||||
var annotation = pageFacade.AddTextMarkupAnnotation(rectangle, devExpressStyle);
|
||||
|
||||
if (annotation != null)
|
||||
{
|
||||
annotation.Color = color;
|
||||
if (!string.IsNullOrWhiteSpace(author))
|
||||
annotation.Author = author;
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
annotation.Contents = content;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddFreeTextAnnotation(
|
||||
PdfPageFacade pageFacade,
|
||||
PdfRectangle rectangle,
|
||||
string content,
|
||||
string? author,
|
||||
PdfRGBColor color)
|
||||
{
|
||||
var annotation = pageFacade.AddFreeTextAnnotation(rectangle, content);
|
||||
|
||||
if (annotation != null)
|
||||
{
|
||||
annotation.Color = color;
|
||||
if (!string.IsNullOrWhiteSpace(author))
|
||||
annotation.Author = author;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddStickyNoteAnnotation(
|
||||
PdfPageFacade pageFacade,
|
||||
PdfRectangle rectangle,
|
||||
string content,
|
||||
string? author,
|
||||
PdfRGBColor color)
|
||||
{
|
||||
// Sticky note uses a point (top-left corner of rectangle)
|
||||
var point = new PdfPoint(rectangle.Left, rectangle.Top);
|
||||
var annotation = pageFacade.AddTextAnnotation(point);
|
||||
|
||||
if (annotation != null)
|
||||
{
|
||||
annotation.Color = color;
|
||||
annotation.Contents = content;
|
||||
if (!string.IsNullOrWhiteSpace(author))
|
||||
annotation.Author = author;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddCircleAnnotation(
|
||||
PdfPageFacade pageFacade,
|
||||
PdfRectangle rectangle,
|
||||
string? content,
|
||||
string? author,
|
||||
PdfRGBColor color)
|
||||
{
|
||||
var annotation = pageFacade.AddCircleAnnotation(rectangle);
|
||||
|
||||
if (annotation != null)
|
||||
{
|
||||
annotation.Color = color;
|
||||
if (!string.IsNullOrWhiteSpace(author))
|
||||
annotation.Author = author;
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
annotation.Contents = content;
|
||||
}
|
||||
}
|
||||
|
||||
private void AddSquareAnnotation(
|
||||
PdfPageFacade pageFacade,
|
||||
PdfRectangle rectangle,
|
||||
string? content,
|
||||
string? author,
|
||||
PdfRGBColor color)
|
||||
{
|
||||
var annotation = pageFacade.AddSquareAnnotation(rectangle);
|
||||
|
||||
if (annotation != null)
|
||||
{
|
||||
annotation.Color = color;
|
||||
if (!string.IsNullOrWhiteSpace(author))
|
||||
annotation.Author = author;
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
annotation.Contents = content;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses hex color string (e.g., "FF0000" for red) to PdfRGBColor
|
||||
/// </summary>
|
||||
private PdfRGBColor? ParseColor(string? hexColor)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hexColor))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
// Remove '#' if present
|
||||
hexColor = hexColor.TrimStart('#');
|
||||
|
||||
if (hexColor.Length != 6)
|
||||
throw new BadRequestException($"Color must be 6-digit hex (e.g., 'FF0000'), got: '{hexColor}'");
|
||||
|
||||
int r = Convert.ToInt32(hexColor.Substring(0, 2), 16);
|
||||
int g = Convert.ToInt32(hexColor.Substring(2, 2), 16);
|
||||
int b = Convert.ToInt32(hexColor.Substring(4, 2), 16);
|
||||
|
||||
return new PdfRGBColor(r / 255.0, g / 255.0, b / 255.0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new BadRequestException($"Invalid color format: '{hexColor}'. Expected 6-digit hex (e.g., 'FF0000'). Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Private Helpers
|
||||
@@ -319,5 +762,335 @@ public class DevExpressPdfProcessor : IPdfProcessor
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses page range string into list of zero-based page indices.
|
||||
/// </summary>
|
||||
/// <param name="pageRange">Page range string (e.g., "1-3,5" or "1,3,5")</param>
|
||||
/// <param name="totalPages">Total page count in PDF (for validation)</param>
|
||||
/// <returns>List of zero-based page indices</returns>
|
||||
/// <exception cref="BadRequestException">Invalid format or page number out of range</exception>
|
||||
private static List<int> ParsePageRange(string pageRange, int totalPages)
|
||||
{
|
||||
var pageIndices = new HashSet<int>(); // Use HashSet to avoid duplicates
|
||||
|
||||
try
|
||||
{
|
||||
// Split by comma
|
||||
string[] parts = pageRange.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
foreach (string part in parts)
|
||||
{
|
||||
if (part.Contains('-'))
|
||||
{
|
||||
// Range format: "1-3"
|
||||
string[] rangeParts = part.Split('-', StringSplitOptions.TrimEntries);
|
||||
if (rangeParts.Length != 2)
|
||||
throw new BadRequestException($"Invalid page range format: '{part}'. Expected format: '1-3'");
|
||||
|
||||
if (!int.TryParse(rangeParts[0], out int start) || !int.TryParse(rangeParts[1], out int end))
|
||||
throw new BadRequestException($"Invalid page numbers in range: '{part}'");
|
||||
|
||||
if (start < 1 || end < 1)
|
||||
throw new BadRequestException($"Page numbers must be >= 1 in range: '{part}'");
|
||||
|
||||
if (start > end)
|
||||
throw new BadRequestException($"Start page must be <= end page in range: '{part}'");
|
||||
|
||||
if (start > totalPages || end > totalPages)
|
||||
throw new BadRequestException($"Page range '{part}' exceeds document page count ({totalPages})");
|
||||
|
||||
// Add pages (convert to zero-based indices)
|
||||
for (int i = start; i <= end; i++)
|
||||
pageIndices.Add(i - 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single page: "5"
|
||||
if (!int.TryParse(part, out int pageNum))
|
||||
throw new BadRequestException($"Invalid page number: '{part}'");
|
||||
|
||||
if (pageNum < 1)
|
||||
throw new BadRequestException($"Page number must be >= 1: '{part}'");
|
||||
|
||||
if (pageNum > totalPages)
|
||||
throw new BadRequestException($"Page number {pageNum} exceeds document page count ({totalPages})");
|
||||
|
||||
pageIndices.Add(pageNum - 1); // Convert to zero-based index
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (BadRequestException)
|
||||
{
|
||||
throw; // Re-throw BadRequestException as-is
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new BadRequestException($"Invalid page range format: '{pageRange}'. Error: {ex.Message}");
|
||||
}
|
||||
|
||||
if (pageIndices.Count == 0)
|
||||
throw new BadRequestException($"Page range '{pageRange}' resulted in no pages");
|
||||
|
||||
return pageIndices.OrderBy(x => x).ToList(); // Return sorted list
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AddStampAsync
|
||||
|
||||
public async Task<byte[]> AddStampAsync(
|
||||
Stream pdfStream,
|
||||
Domain.Models.ValueObjects.StampType stampType,
|
||||
int[]? pageNumbers,
|
||||
(double X, double Y) position,
|
||||
(double Width, double Height)? size = null,
|
||||
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft,
|
||||
string? text = null,
|
||||
string? fontName = null,
|
||||
double? fontSize = null,
|
||||
string? color = null,
|
||||
double? opacity = null,
|
||||
double? rotation = null,
|
||||
Domain.Models.ValueObjects.StampPlacement placement = Domain.Models.ValueObjects.StampPlacement.Foreground,
|
||||
byte[]? imageBytes = null,
|
||||
Domain.Models.ValueObjects.PredefinedStampType? predefinedType = null)
|
||||
{
|
||||
// 1. Validate stream
|
||||
if (pdfStream == null || pdfStream.Length == 0)
|
||||
throw new BadRequestException("PDF stream cannot be null or empty");
|
||||
|
||||
if (pdfStream.Position != 0)
|
||||
throw new BadRequestException("PDF stream position must be 0");
|
||||
|
||||
// 2. Validate stamp type requirements
|
||||
ValidateStampParameters(stampType, text, imageBytes, predefinedType);
|
||||
|
||||
// 3. Validate optional parameters
|
||||
if (opacity.HasValue && (opacity.Value < 0.0 || opacity.Value > 1.0))
|
||||
throw new BadRequestException("Opacity must be between 0.0 and 1.0");
|
||||
|
||||
if (rotation.HasValue && (rotation.Value < 0 || rotation.Value > 360))
|
||||
throw new BadRequestException("Rotation must be between 0 and 360 degrees");
|
||||
|
||||
if (fontSize.HasValue && fontSize.Value <= 0)
|
||||
throw new BadRequestException("Font size must be positive");
|
||||
|
||||
// 4. Load PDF and calculate target pages
|
||||
using var processor = new PdfDocumentProcessor();
|
||||
try
|
||||
{
|
||||
processor.LoadDocument(pdfStream);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new BadRequestException($"Failed to load PDF document: {ex.Message}");
|
||||
}
|
||||
|
||||
int totalPages = processor.Document.Pages.Count;
|
||||
int[] targetPages = CalculateTargetPages(totalPages, pageNumbers);
|
||||
|
||||
// 5. Apply stamp to each target page
|
||||
foreach (int pageIndex in targetPages)
|
||||
{
|
||||
double pageHeight = processor.Document.Pages[pageIndex].CropBox.Height;
|
||||
|
||||
// Convert position if origin is TopLeft
|
||||
var stampPosition = origin == Domain.Models.ValueObjects.AnnotationOrigin.TopLeft
|
||||
? (position.X, pageHeight - position.Y)
|
||||
: position;
|
||||
|
||||
// Apply stamp based on type
|
||||
switch (stampType)
|
||||
{
|
||||
case Domain.Models.ValueObjects.StampType.Text:
|
||||
AddTextStamp(processor, pageIndex, stampPosition, text!, fontName, fontSize, color, opacity, rotation, placement, size);
|
||||
break;
|
||||
|
||||
case Domain.Models.ValueObjects.StampType.Image:
|
||||
AddImageStamp(processor, pageIndex, stampPosition, imageBytes!, opacity, rotation, placement, size);
|
||||
break;
|
||||
|
||||
case Domain.Models.ValueObjects.StampType.Predefined:
|
||||
AddPredefinedStamp(processor, pageIndex, stampPosition, predefinedType!.Value, opacity, rotation, placement, size);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Save to byte array
|
||||
using var outputStream = new MemoryStream();
|
||||
processor.SaveDocument(outputStream);
|
||||
return await Task.FromResult(outputStream.ToArray());
|
||||
}
|
||||
|
||||
private void ValidateStampParameters(
|
||||
Domain.Models.ValueObjects.StampType stampType,
|
||||
string? text,
|
||||
byte[]? imageBytes,
|
||||
Domain.Models.ValueObjects.PredefinedStampType? predefinedType)
|
||||
{
|
||||
switch (stampType)
|
||||
{
|
||||
case Domain.Models.ValueObjects.StampType.Text:
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
throw new BadRequestException("Text is required for Text stamp type");
|
||||
break;
|
||||
|
||||
case Domain.Models.ValueObjects.StampType.Image:
|
||||
if (imageBytes == null || imageBytes.Length == 0)
|
||||
throw new BadRequestException("Image bytes are required for Image stamp type");
|
||||
break;
|
||||
|
||||
case Domain.Models.ValueObjects.StampType.Predefined:
|
||||
if (!predefinedType.HasValue)
|
||||
throw new BadRequestException("Predefined type is required for Predefined stamp type");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private int[] CalculateTargetPages(int totalPages, int[]? pageNumbers)
|
||||
{
|
||||
// null = all pages
|
||||
if (pageNumbers == null)
|
||||
return Enumerable.Range(0, totalPages).ToArray();
|
||||
|
||||
// Validate page numbers (1-based)
|
||||
foreach (int pageNum in pageNumbers)
|
||||
{
|
||||
if (pageNum < 1 || pageNum > totalPages)
|
||||
throw new BadRequestException($"Page number {pageNum} is out of range (1-{totalPages})");
|
||||
}
|
||||
|
||||
// Convert to zero-based indices
|
||||
return pageNumbers.Select(p => p - 1).Distinct().OrderBy(p => p).ToArray();
|
||||
}
|
||||
|
||||
private void AddTextStamp(
|
||||
PdfDocumentProcessor processor,
|
||||
int pageIndex,
|
||||
(double X, double Y) position,
|
||||
string text,
|
||||
string? fontName,
|
||||
double? fontSize,
|
||||
string? color,
|
||||
double? opacity,
|
||||
double? rotation,
|
||||
Domain.Models.ValueObjects.StampPlacement placement,
|
||||
(double Width, double Height)? size)
|
||||
{
|
||||
using var graphics = processor.CreateGraphicsPageSystem();
|
||||
|
||||
// Get page object
|
||||
PdfPage page = processor.Document.Pages[pageIndex];
|
||||
|
||||
// Parse color (default: black)
|
||||
var pdfColor = ParseColor(color) ?? new PdfRGBColor(0, 0, 0);
|
||||
|
||||
// Apply opacity (default: 0.5) by creating color with alpha channel
|
||||
double alpha = opacity ?? 0.5;
|
||||
Color drawColor = Color.FromArgb((int)(alpha * 255), (int)(pdfColor.R * 255), (int)(pdfColor.G * 255), (int)(pdfColor.B * 255));
|
||||
|
||||
// Create font (default: Arial, 12pt)
|
||||
var font = new DXFont(fontName ?? "Arial", (float)(fontSize ?? 12));
|
||||
|
||||
// Calculate bounds
|
||||
var bounds = size.HasValue
|
||||
? new RectangleF((float)position.X, (float)position.Y, (float)size.Value.Width, (float)size.Value.Height)
|
||||
: new RectangleF((float)position.X, (float)position.Y, 200, 50); // Default size
|
||||
|
||||
// Apply rotation if specified (around origin, not center point)
|
||||
if (rotation.HasValue && rotation.Value > 0)
|
||||
{
|
||||
// Translate to position, rotate, translate back
|
||||
graphics.TranslateTransform((float)position.X, (float)position.Y);
|
||||
graphics.RotateTransform((float)rotation.Value);
|
||||
graphics.TranslateTransform(-(float)position.X, -(float)position.Y);
|
||||
}
|
||||
|
||||
// Draw text
|
||||
graphics.DrawString(text, font, new DXSolidBrush(drawColor), bounds);
|
||||
|
||||
// Add graphics to page (foreground or background)
|
||||
if (placement == Domain.Models.ValueObjects.StampPlacement.Foreground)
|
||||
graphics.AddToPageForeground(page);
|
||||
else
|
||||
graphics.AddToPageBackground(page);
|
||||
}
|
||||
|
||||
private void AddImageStamp(
|
||||
PdfDocumentProcessor processor,
|
||||
int pageIndex,
|
||||
(double X, double Y) position,
|
||||
byte[] imageBytes,
|
||||
double? opacity,
|
||||
double? rotation,
|
||||
Domain.Models.ValueObjects.StampPlacement placement,
|
||||
(double Width, double Height)? size)
|
||||
{
|
||||
using var graphics = processor.CreateGraphicsPageSystem();
|
||||
|
||||
// Get page object
|
||||
PdfPage page = processor.Document.Pages[pageIndex];
|
||||
|
||||
// Draw image directly from byte array
|
||||
try
|
||||
{
|
||||
PointF point = new PointF((float)position.X, (float)position.Y);
|
||||
|
||||
// Apply rotation if specified
|
||||
if (rotation.HasValue && rotation.Value > 0)
|
||||
{
|
||||
graphics.TranslateTransform((float)position.X, (float)position.Y);
|
||||
graphics.RotateTransform((float)rotation.Value);
|
||||
graphics.TranslateTransform(-(float)position.X, -(float)position.Y);
|
||||
}
|
||||
|
||||
// Draw image (DevExpress.Pdf.PdfGraphics.DrawImage accepts byte[] directly)
|
||||
// Note: Size parameter is ignored for now (DrawImage auto-sizes based on image dimensions)
|
||||
// If size is needed, we'd need to use DXImage.FromStream and resize
|
||||
graphics.DrawImage(imageBytes, point);
|
||||
|
||||
// Add graphics to page
|
||||
if (placement == Domain.Models.ValueObjects.StampPlacement.Foreground)
|
||||
graphics.AddToPageForeground(page);
|
||||
else
|
||||
graphics.AddToPageBackground(page);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new BadRequestException($"Invalid image format or failed to draw image: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void AddPredefinedStamp(
|
||||
PdfDocumentProcessor processor,
|
||||
int pageIndex,
|
||||
(double X, double Y) position,
|
||||
Domain.Models.ValueObjects.PredefinedStampType predefinedType,
|
||||
double? opacity,
|
||||
double? rotation,
|
||||
Domain.Models.ValueObjects.StampPlacement placement,
|
||||
(double Width, double Height)? size)
|
||||
{
|
||||
// Get predefined stamp configuration
|
||||
var (text, color, fontSize, fontStyle) = GetPredefinedStampConfig(predefinedType);
|
||||
|
||||
// Delegate to AddTextStamp with predefined parameters
|
||||
AddTextStamp(processor, pageIndex, position, text, "Arial", fontSize, color, opacity, rotation, placement, size);
|
||||
}
|
||||
|
||||
private (string Text, string Color, double FontSize, string FontStyle) GetPredefinedStampConfig(
|
||||
Domain.Models.ValueObjects.PredefinedStampType predefinedType)
|
||||
{
|
||||
return predefinedType switch
|
||||
{
|
||||
Domain.Models.ValueObjects.PredefinedStampType.Confidential => ("CONFIDENTIAL", "FF0000", 24, "Bold"),
|
||||
Domain.Models.ValueObjects.PredefinedStampType.Approved => ("APPROVED", "00AA00", 24, "Bold"),
|
||||
Domain.Models.ValueObjects.PredefinedStampType.Draft => ("DRAFT", "808080", 24, "Italic"),
|
||||
Domain.Models.ValueObjects.PredefinedStampType.Void => ("VOID", "FF0000", 32, "Bold"),
|
||||
Domain.Models.ValueObjects.PredefinedStampType.ForReview => ("FOR REVIEW", "FFA500", 20, "Bold"),
|
||||
_ => throw new BadRequestException($"Unsupported predefined stamp type: {predefinedType}")
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -24,19 +24,27 @@ public sealed class DevExpressSwissQrCodeProcessor : ISwissQrCodeProcessor
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(Bill Bill, string[] RawLines)> ExtractSwissQrCodeAsync(
|
||||
byte[] pdfBytes,
|
||||
Stream pdfStream,
|
||||
int[]? pageNumbers = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (pdfBytes.Length == 0)
|
||||
throw new ArgumentException("PDF document contains no byte data.", nameof(pdfBytes));
|
||||
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
|
||||
|
||||
if (pdfStream.Length == 0)
|
||||
throw new ArgumentException("PDF stream is empty.", nameof(pdfStream));
|
||||
|
||||
// Defensive validation: Seekable streams must be at Position = 0
|
||||
// Non-seekable streams (e.g., NetworkStream) are not checked
|
||||
if (pdfStream.CanSeek && pdfStream.Position != 0)
|
||||
throw new BadRequestException(null, new ArgumentException(
|
||||
"PDF stream must be positioned at the beginning (Position = 0).",
|
||||
nameof(pdfStream)));
|
||||
|
||||
using var pdfDocument = new PdfDocumentProcessor();
|
||||
using var pdfStream = new MemoryStream(pdfBytes);
|
||||
pdfDocument.LoadDocument(pdfStream);
|
||||
|
||||
if (pdfDocument.Document.Pages.Count == 0)
|
||||
throw new ArgumentException("PDF document contains no pages.", nameof(pdfBytes));
|
||||
throw new ArgumentException("PDF document contains no pages.", nameof(pdfStream));
|
||||
|
||||
// Determine which pages to scan
|
||||
int[] pagesToScan = DeterminePageNumbers(pdfDocument.Document.Pages.Count, pageNumbers);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using DocumentOperator.API.Controllers; // For ExtractSwissQrCodeBase64Request DTO
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Application.SwissQrCode.Queries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using System.Net;
|
||||
@@ -29,7 +29,7 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
|
||||
// Arrange
|
||||
var validPdfBase64 = GetEmbeddedResourceAsBase64("DocumentOperator.Tests.TestData.Pdfs.valid.pdf");
|
||||
|
||||
var request = new ExtractSwissQrCodeQuery
|
||||
var request = new ExtractSwissQrCodeBase64Request
|
||||
{
|
||||
Base64Pdf = validPdfBase64
|
||||
};
|
||||
@@ -56,7 +56,7 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
|
||||
public async Task POST_ExtractSwissQrCode_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExtractSwissQrCodeQuery
|
||||
var request = new ExtractSwissQrCodeBase64Request
|
||||
{
|
||||
Base64Pdf = "INVALID_BASE64!!!"
|
||||
};
|
||||
@@ -66,6 +66,10 @@ public sealed class ExtractSwissQrCodeEndpointTests : IClassFixture<WebApplicati
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
// FormatException message contains "Base-64" (with hyphen)
|
||||
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using DocumentOperator.Application.CheckPdfAttachments.Queries;
|
||||
using DocumentOperator.API.Controllers; // For CheckPdfAttachmentsRequest DTO
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
@@ -54,7 +54,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
|
||||
// Arrange
|
||||
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithSwissQRCode.pdf");
|
||||
string base64Pdf = Convert.ToBase64String(pdfBytes);
|
||||
var request = new CheckPdfAttachmentsQuery { Base64Pdf = base64Pdf };
|
||||
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
|
||||
@@ -75,7 +75,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
|
||||
// Arrange
|
||||
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
|
||||
string base64Pdf = Convert.ToBase64String(pdfBytes);
|
||||
var request = new CheckPdfAttachmentsQuery { Base64Pdf = base64Pdf };
|
||||
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
|
||||
@@ -102,7 +102,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
|
||||
public async Task POST_CheckAttachments_Base64_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CheckPdfAttachmentsQuery { Base64Pdf = "invalid-base64!!!" };
|
||||
var request = new CheckPdfAttachmentsRequest { Base64Pdf = "invalid-base64!!!" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
|
||||
@@ -111,14 +111,15 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
problemDetails.Should().Contain("Base64");
|
||||
// FormatException message contains "Base-64" (with hyphen)
|
||||
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_CheckAttachments_Base64_EmptyPdf_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new CheckPdfAttachmentsQuery { Base64Pdf = string.Empty };
|
||||
var request = new CheckPdfAttachmentsRequest { Base64Pdf = string.Empty };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
|
||||
@@ -127,7 +128,8 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
problemDetails.Should().Contain("Either PdfBytes or Base64Pdf must be provided");
|
||||
// Empty Base64 causes FormatException or empty stream error
|
||||
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -233,7 +235,7 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
|
||||
// Arrange
|
||||
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithSwissQRCode.pdf");
|
||||
string base64Pdf = Convert.ToBase64String(pdfBytes);
|
||||
var request = new CheckPdfAttachmentsQuery { Base64Pdf = base64Pdf };
|
||||
var request = new CheckPdfAttachmentsRequest { Base64Pdf = base64Pdf };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/check", request);
|
||||
@@ -248,4 +250,134 @@ public class PdfAttachmentControllerTests : IClassFixture<WebApplicationFactory<
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extract Attachments Tests (Multipart)
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ExtractAttachments_Multipart_ValidPdfWithAttachments_Returns200WithZip()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(pdfBytes);
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(fileContent, "file", "test.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/attachments/extract", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.Should().Be("application/zip");
|
||||
response.Content.Headers.ContentDisposition?.FileName.Should().Be("attachments.zip");
|
||||
|
||||
byte[] zipBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
zipBytes.Should().NotBeEmpty("ZIP should contain data");
|
||||
|
||||
// Verify ZIP structure
|
||||
using var zipStream = new MemoryStream(zipBytes);
|
||||
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
|
||||
|
||||
zipArchive.Entries.Should().HaveCount(6, "PDF contains 6 attachments");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ExtractAttachments_Multipart_EmptyPdf_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(Array.Empty<byte>());
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(fileContent, "file", "empty.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/attachments/extract", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ExtractAttachments_Multipart_CorruptedPdf_Returns500()
|
||||
{
|
||||
// Arrange
|
||||
byte[] corruptedBytes = "This is not a valid PDF content"u8.ToArray();
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
var fileContent = new ByteArrayContent(corruptedBytes);
|
||||
fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(fileContent, "file", "corrupted.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/attachments/extract", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Extract Attachments Tests (Base64)
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ExtractAttachments_Base64_ValidPdfWithAttachments_Returns200WithZip()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = await LoadTestPdfAsync("pdfWithMoreThanOneAttachment.pdf");
|
||||
string base64Pdf = Convert.ToBase64String(pdfBytes);
|
||||
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = base64Pdf };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.Should().Be("application/zip");
|
||||
response.Content.Headers.ContentDisposition?.FileName.Should().Be("attachments.zip");
|
||||
|
||||
byte[] zipBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
zipBytes.Should().NotBeEmpty("ZIP should contain data");
|
||||
|
||||
// Verify ZIP structure
|
||||
using var zipStream = new MemoryStream(zipBytes);
|
||||
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
|
||||
|
||||
zipArchive.Entries.Should().HaveCount(6, "PDF contains 6 attachments");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ExtractAttachments_Base64_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = "invalid-base64!!!" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
// FormatException message contains "Base-64" (with hyphen)
|
||||
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ExtractAttachments_Base64_EmptyPdf_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ExtractPdfAttachmentsRequest { Base64Pdf = string.Empty };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/attachments/extract", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,500 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DocumentOperator.API.Controllers;
|
||||
using DocumentOperator.Domain.Models.ValueObjects;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace DocumentOperator.Tests.Integration.API;
|
||||
|
||||
public class PdfOperationsControllerTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public PdfOperationsControllerTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_client = factory.CreateClient();
|
||||
}
|
||||
|
||||
private static Stream LoadTestPdfAsStream(string fileName)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceName = $"DocumentOperator.Tests.TestData.Pdfs.{fileName}";
|
||||
return assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new FileNotFoundException($"Embedded resource not found: {resourceName}");
|
||||
}
|
||||
|
||||
private static string LoadTestPdfAsBase64(string fileName)
|
||||
{
|
||||
using var stream = LoadTestPdfAsStream(fileName);
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
return Convert.ToBase64String(ms.ToArray());
|
||||
}
|
||||
|
||||
#region Merge Endpoint Tests (existing - keeping for reference)
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromFiles_ValidPdfs_ReturnsMergedPdf()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
using var pdf1Stream = LoadTestPdfAsStream("valid.pdf");
|
||||
using var pdf2Stream = LoadTestPdfAsStream("valid.pdf");
|
||||
|
||||
var pdf1Content = new StreamContent(pdf1Stream);
|
||||
var pdf2Content = new StreamContent(pdf2Stream);
|
||||
|
||||
pdf1Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
pdf2Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
|
||||
content.Add(pdf1Content, "files", "file1.pdf");
|
||||
content.Add(pdf2Content, "files", "file2.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.Should().Be("application/pdf");
|
||||
|
||||
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
mergedPdf.Should().NotBeEmpty();
|
||||
mergedPdf.Length.Should().BeGreaterThan(100); // Sanity check
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromBase64_ValidPdfs_ReturnsMergedPdf()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf1 = LoadTestPdfAsBase64("valid.pdf");
|
||||
string base64Pdf2 = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new MergePdfsBase64Request
|
||||
{
|
||||
Base64Pdfs = new List<string> { base64Pdf1, base64Pdf2 },
|
||||
PageRanges = null
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
mergedPdf.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromBase64_WithPageRanges_ReturnsMergedPdf()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf1 = LoadTestPdfAsBase64("valid.pdf");
|
||||
string base64Pdf2 = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new MergePdfsBase64Request
|
||||
{
|
||||
Base64Pdfs = new List<string> { base64Pdf1, base64Pdf2 },
|
||||
PageRanges = new List<string?> { "1", "1" } // Only first page from each
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
byte[] mergedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
mergedPdf.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromFiles_OnePdf_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
var pdfContent = new StreamContent(pdfStream);
|
||||
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(pdfContent, "files", "file1.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromBase64_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new MergePdfsBase64Request
|
||||
{
|
||||
Base64Pdfs = new List<string> { "INVALID_BASE64!!!", "ANOTHER_INVALID" }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromBase64_InvalidPageRange_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new MergePdfsBase64Request
|
||||
{
|
||||
Base64Pdfs = new List<string> { base64Pdf, base64Pdf },
|
||||
PageRanges = new List<string?> { "999-1000", null } // Exceeds page count
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeFromFiles_CorruptedPdf_Returns500()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
byte[] corruptedData = "NOT A PDF FILE"u8.ToArray();
|
||||
var corruptedContent = new ByteArrayContent(corruptedData);
|
||||
corruptedContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
|
||||
using var validPdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
var validContent = new StreamContent(validPdfStream);
|
||||
validContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
|
||||
content.Add(corruptedContent, "files", "corrupted.pdf");
|
||||
content.Add(validContent, "files", "valid.pdf");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/merge", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.InternalServerError);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Annotate Endpoint Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromFile_TextMarkupHighlight_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
var pdfContent = new StreamContent(pdfStream);
|
||||
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(pdfContent, "file", "test.pdf");
|
||||
|
||||
content.Add(new StringContent(AnnotationType.TextMarkup.ToString()), "annotationType");
|
||||
content.Add(new StringContent("1"), "pageNumber");
|
||||
content.Add(new StringContent("100"), "x1");
|
||||
content.Add(new StringContent("100"), "y1");
|
||||
content.Add(new StringContent("200"), "x2");
|
||||
content.Add(new StringContent("120"), "y2");
|
||||
content.Add(new StringContent("Important text"), "content");
|
||||
content.Add(new StringContent("Test Author"), "author");
|
||||
content.Add(new StringContent("FFFF00"), "color");
|
||||
content.Add(new StringContent(TextMarkupStyle.Highlight.ToString()), "textMarkupStyle");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
response.Content.Headers.ContentType?.MediaType.Should().Be("application/pdf");
|
||||
|
||||
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
annotatedPdf.Should().NotBeEmpty();
|
||||
annotatedPdf.Length.Should().BeGreaterThan(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_FreeText_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.FreeText,
|
||||
PageNumber = 1,
|
||||
X1 = 50,
|
||||
Y1 = 50,
|
||||
X2 = 150,
|
||||
Y2 = 100,
|
||||
Content = "Free text annotation",
|
||||
Author = "John Doe",
|
||||
Color = "FF0000"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
annotatedPdf.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_StickyNote_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.StickyNote,
|
||||
PageNumber = 1,
|
||||
X1 = 300,
|
||||
Y1 = 300,
|
||||
X2 = 320,
|
||||
Y2 = 320,
|
||||
Content = "Please review this section",
|
||||
Author = "Reviewer"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
annotatedPdf.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromFile_Circle_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
var pdfContent = new StreamContent(pdfStream);
|
||||
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
content.Add(pdfContent, "file", "test.pdf");
|
||||
|
||||
content.Add(new StringContent(AnnotationType.Circle.ToString()), "annotationType");
|
||||
content.Add(new StringContent("1"), "pageNumber");
|
||||
content.Add(new StringContent("100"), "x1");
|
||||
content.Add(new StringContent("200"), "y1");
|
||||
content.Add(new StringContent("200"), "x2");
|
||||
content.Add(new StringContent("300"), "y2");
|
||||
content.Add(new StringContent("Circle annotation"), "content");
|
||||
content.Add(new StringContent("00FF00"), "color");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
annotatedPdf.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_Square_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.Square,
|
||||
PageNumber = 1,
|
||||
X1 = 250,
|
||||
Y1 = 250,
|
||||
X2 = 350,
|
||||
Y2 = 350,
|
||||
Color = "0000FF"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
byte[] annotatedPdf = await response.Content.ReadAsByteArrayAsync();
|
||||
annotatedPdf.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = "INVALID_BASE64!!!",
|
||||
AnnotationType = AnnotationType.Circle,
|
||||
PageNumber = 1,
|
||||
X1 = 0,
|
||||
Y1 = 0,
|
||||
X2 = 100,
|
||||
Y2 = 100
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_InvalidPageNumber_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.FreeText,
|
||||
PageNumber = 999, // Exceeds page count
|
||||
X1 = 0,
|
||||
Y1 = 0,
|
||||
X2 = 100,
|
||||
Y2 = 100,
|
||||
Content = "Test"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_FreeTextWithoutContent_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.FreeText,
|
||||
PageNumber = 1,
|
||||
X1 = 0,
|
||||
Y1 = 0,
|
||||
X2 = 100,
|
||||
Y2 = 100
|
||||
// Missing required Content
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_TextMarkupWithoutStyle_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.TextMarkup,
|
||||
PageNumber = 1,
|
||||
X1 = 0,
|
||||
Y1 = 0,
|
||||
X2 = 100,
|
||||
Y2 = 100
|
||||
// Missing required TextMarkupStyle
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnnotateFromBase64_InvalidColorFormat_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
string base64Pdf = LoadTestPdfAsBase64("valid.pdf");
|
||||
|
||||
var request = new AddAnnotationBase64Command
|
||||
{
|
||||
Base64Pdf = base64Pdf,
|
||||
AnnotationType = AnnotationType.Circle,
|
||||
PageNumber = 1,
|
||||
X1 = 0,
|
||||
Y1 = 0,
|
||||
X2 = 100,
|
||||
Y2 = 100,
|
||||
Color = "INVALID" // Invalid hex format
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsync("/api/pdf/operations/annotate", content);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using DocumentOperator.API.Controllers; // For Base64Request DTOs
|
||||
using DocumentOperator.Application.Common.DTOs;
|
||||
using DocumentOperator.Application.ValidatePdf.Queries;
|
||||
using DocumentOperator.Application.ValidatePdfA.Queries;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using System.Net;
|
||||
@@ -44,7 +43,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
}
|
||||
|
||||
var base64Pdf = Convert.ToBase64String(pdfBytes);
|
||||
var request = new ValidatePdfQuery { Base64Pdf = base64Pdf };
|
||||
var request = new ValidatePdfBase64Request { Base64Pdf = base64Pdf };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
|
||||
@@ -63,7 +62,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
public async Task POST_ValidatePdf_Base64_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ValidatePdfQuery { Base64Pdf = "invalid-base64!!!" }; // Kein gültiges Base64
|
||||
var request = new ValidatePdfBase64Request { Base64Pdf = "invalid-base64!!!" }; // Kein gültiges Base64
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
|
||||
@@ -72,14 +71,15 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
problemDetails.Should().Contain("Base64");
|
||||
// FormatException message contains "Base-64" (with hyphen)
|
||||
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ValidatePdf_Base64_EmptyPdf_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ValidatePdfQuery { Base64Pdf = string.Empty }; // Leerer String
|
||||
var request = new ValidatePdfBase64Request { Base64Pdf = string.Empty }; // Leerer String
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate", request);
|
||||
@@ -88,7 +88,8 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
problemDetails.Should().Contain("Either PdfBytes or Base64Pdf must be provided");
|
||||
// Empty Base64 causes FormatException or empty stream error
|
||||
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -191,7 +192,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
}
|
||||
|
||||
var base64Pdf = Convert.ToBase64String(pdfBytes);
|
||||
var request = new ValidatePdfAQuery { Base64Pdf = base64Pdf };
|
||||
var request = new ValidatePdfABase64Request { Base64Pdf = base64Pdf };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
|
||||
@@ -210,7 +211,7 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
public async Task POST_ValidatePdfA_Base64_InvalidBase64_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ValidatePdfAQuery { Base64Pdf = "invalid-base64!!!" };
|
||||
var request = new ValidatePdfABase64Request { Base64Pdf = "invalid-base64!!!" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
|
||||
@@ -219,14 +220,15 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
problemDetails.Should().Contain("Base64");
|
||||
// FormatException message contains "Base-64" (with hyphen)
|
||||
problemDetails.Should().MatchRegex("(?i)base.?64", "should contain Base64 validation error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task POST_ValidatePdfA_Base64_EmptyPdf_Returns400()
|
||||
{
|
||||
// Arrange
|
||||
var request = new ValidatePdfAQuery { Base64Pdf = string.Empty };
|
||||
var request = new ValidatePdfABase64Request { Base64Pdf = string.Empty };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/pdf/validation/validate-pdfa", request);
|
||||
@@ -235,7 +237,8 @@ public class PdfValidationControllerTests : IClassFixture<WebApplicationFactory<
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
|
||||
var problemDetails = await response.Content.ReadAsStringAsync();
|
||||
problemDetails.Should().Contain("Either PdfBytes or Base64Pdf must be provided");
|
||||
// Empty Base64 causes FormatException or empty stream error
|
||||
problemDetails.Should().MatchRegex("(Base64|empty|stream)", "should contain validation error message");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -30,7 +30,7 @@ public class CheckPdfAttachmentsQueryHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = "fake-pdf-content"u8.ToArray();
|
||||
var query = new CheckPdfAttachmentsQuery { PdfBytes = pdfBytes };
|
||||
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var domainResult = new AttachmentInfo(
|
||||
hasAttachments: true,
|
||||
@@ -72,8 +72,7 @@ public class CheckPdfAttachmentsQueryHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = "fake-pdf-content"u8.ToArray();
|
||||
string base64Pdf = Convert.ToBase64String(pdfBytes);
|
||||
var query = new CheckPdfAttachmentsQuery { Base64Pdf = base64Pdf };
|
||||
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var domainResult = new AttachmentInfo(false, 0, []);
|
||||
var expectedDto = new AttachmentCheckResult { HasAttachments = false, AttachmentCount = 0, Attachments = [] };
|
||||
@@ -97,7 +96,7 @@ public class CheckPdfAttachmentsQueryHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = "fake-pdf-content"u8.ToArray();
|
||||
var query = new CheckPdfAttachmentsQuery { PdfBytes = pdfBytes };
|
||||
var query = new CheckPdfAttachmentsQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var domainResult = new AttachmentInfo(false, 0, []);
|
||||
var expectedDto = new AttachmentCheckResult
|
||||
|
||||
@@ -27,7 +27,7 @@ public class ValidatePdfHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var pdfBytes = "%PDF"u8.ToArray(); // "%PDF"
|
||||
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
|
||||
var query = new ValidatePdfQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var domainMetadata = new PdfMetadata(
|
||||
pageCount: 5,
|
||||
@@ -74,7 +74,7 @@ public class ValidatePdfHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var pdfBytes = "%PDF"u8.ToArray(); // "%PDF"
|
||||
var query = new ValidatePdfQuery { PdfBytes = pdfBytes };
|
||||
var query = new ValidatePdfQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
_mockPdfProcessor
|
||||
.Setup(x => x.ValidateAsync(It.IsAny<Stream>()))
|
||||
|
||||
@@ -28,7 +28,7 @@ public class ValidatePdfAQueryHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var pdfBytes = "%PDF"u8.ToArray(); // "%PDF"
|
||||
var query = new ValidatePdfAQuery { PdfBytes = pdfBytes };
|
||||
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var domainMetadata = new PdfAMetadata(
|
||||
isValid: true,
|
||||
@@ -86,7 +86,7 @@ public class ValidatePdfAQueryHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
|
||||
var query = new ValidatePdfAQuery { Base64Pdf = Convert.ToBase64String(pdfBytes) };
|
||||
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var errors = new List<string> { "Missing XMP metadata", "Invalid color space" };
|
||||
var warnings = new List<string> { "Embedded font not subset" };
|
||||
@@ -144,7 +144,7 @@ public class ValidatePdfAQueryHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
|
||||
var query = new ValidatePdfAQuery { PdfBytes = pdfBytes };
|
||||
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
var domainMetadata = new PdfAMetadata(
|
||||
isValid: true,
|
||||
@@ -196,7 +196,7 @@ public class ValidatePdfAQueryHandlerTests
|
||||
{
|
||||
// Arrange
|
||||
var pdfBytes = new byte[] { 0x25, 0x50, 0x44, 0x46 }; // "%PDF"
|
||||
var query = new ValidatePdfAQuery { PdfBytes = pdfBytes };
|
||||
var query = new ValidatePdfAQuery { PdfStream = new MemoryStream(pdfBytes) };
|
||||
|
||||
_mockPdfProcessor
|
||||
.Setup(x => x.ValidatePdfAAsync(It.IsAny<Stream>()))
|
||||
|
||||
@@ -47,6 +47,17 @@ public class DevExpressPdfProcessorTests
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a test PDF from embedded resources as Stream.
|
||||
/// </summary>
|
||||
/// <param name="filename">Name of the PDF file (e.g., "valid.pdf")</param>
|
||||
/// <returns>PDF content as MemoryStream (caller must dispose)</returns>
|
||||
private static MemoryStream LoadTestPdfAsStream(string filename)
|
||||
{
|
||||
byte[] pdfBytes = LoadTestPdf(filename);
|
||||
return new MemoryStream(pdfBytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts byte array to MemoryStream for testing
|
||||
/// </summary>
|
||||
@@ -275,8 +286,531 @@ public class DevExpressPdfProcessorTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ExtractAttachmentsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAttachmentsAsync_PdfWithAttachments_ReturnsValidZip()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = LoadTestPdf("pdfWithMoreThanOneAttachment.pdf");
|
||||
|
||||
// Act
|
||||
byte[] zipBytes = await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes));
|
||||
|
||||
// Assert
|
||||
zipBytes.Should().NotBeEmpty("ZIP should contain data");
|
||||
|
||||
// Verify ZIP header (PK signature)
|
||||
zipBytes[0].Should().Be(0x50, "ZIP magic bytes start with 'P'");
|
||||
zipBytes[1].Should().Be(0x4B, "ZIP magic bytes continue with 'K'");
|
||||
|
||||
// Verify ZIP can be opened
|
||||
using var zipStream = new MemoryStream(zipBytes);
|
||||
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
|
||||
|
||||
zipArchive.Entries.Should().HaveCount(6, "PDF contains 6 attachments");
|
||||
}
|
||||
|
||||
[Fact(Skip = "valid.pdf may contain attachments - need dedicated PDF without attachments for this test")]
|
||||
public async Task ExtractAttachmentsAsync_PdfWithoutAttachments_ThrowsNotFoundException()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = LoadTestPdf("valid.pdf"); // No attachments
|
||||
|
||||
// Act
|
||||
Func<Task> act = async () => await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes));
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<NotFoundException>()
|
||||
.WithMessage("*attachments*", "PDF without attachments should throw NotFoundException");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAttachmentsAsync_ExtractedFilesHaveCorrectNames()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = LoadTestPdf("pdfWithMoreThanOneAttachment.pdf");
|
||||
|
||||
// Act
|
||||
byte[] zipBytes = await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes));
|
||||
|
||||
// Assert
|
||||
using var zipStream = new MemoryStream(zipBytes);
|
||||
using var zipArchive = new System.IO.Compression.ZipArchive(zipStream, System.IO.Compression.ZipArchiveMode.Read);
|
||||
|
||||
// Verify all entries have valid names (not null/empty)
|
||||
foreach (var entry in zipArchive.Entries)
|
||||
{
|
||||
entry.Name.Should().NotBeNullOrEmpty("each file should have a name");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAttachmentsAsync_EmptyStream_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange
|
||||
using var emptyStream = new MemoryStream();
|
||||
|
||||
// Act
|
||||
Func<Task> act = async () => await _sut.ExtractAttachmentsAsync(emptyStream);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>()
|
||||
.WithMessage("*empty*", "empty stream should be rejected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractAttachmentsAsync_CorruptedPdf_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = "This is not a valid PDF content"u8.ToArray();
|
||||
|
||||
// Act
|
||||
Func<Task> act = async () => await _sut.ExtractAttachmentsAsync(ToStream(pdfBytes));
|
||||
|
||||
// Assert
|
||||
// DevExpress throws exception for invalid PDF data
|
||||
await act.Should().ThrowAsync<Exception>("corrupted PDF should throw exception");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region MergePdfsAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task MergePdfsAsync_TwoPdfs_ReturnsMergedPdf()
|
||||
{
|
||||
// Arrange
|
||||
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
|
||||
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
|
||||
var streams = new List<Stream> { pdf1, pdf2 };
|
||||
|
||||
// Act
|
||||
byte[] mergedPdf = await _sut.MergePdfsAsync(streams);
|
||||
|
||||
// Assert
|
||||
mergedPdf.Should().NotBeEmpty("merged PDF should contain data");
|
||||
mergedPdf.Length.Should().BeGreaterThan(1000, "merged PDF should be reasonably sized");
|
||||
|
||||
// Verify it's a valid PDF by loading it
|
||||
using var verifyStream = new MemoryStream(mergedPdf);
|
||||
using var verifyProcessor = new DevExpress.Pdf.PdfDocumentProcessor();
|
||||
verifyProcessor.LoadDocument(verifyStream);
|
||||
verifyProcessor.Document.Pages.Count.Should().BeGreaterOrEqualTo(2, "merged PDF should have at least 2 pages");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePdfsAsync_ThreePdfs_ReturnsMergedPdf()
|
||||
{
|
||||
// Arrange
|
||||
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
|
||||
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
|
||||
using var pdf3 = LoadTestPdfAsStream("pdfWithMoreThanOneAttachment.pdf");
|
||||
var streams = new List<Stream> { pdf1, pdf2, pdf3 };
|
||||
|
||||
// Act
|
||||
byte[] mergedPdf = await _sut.MergePdfsAsync(streams);
|
||||
|
||||
// Assert
|
||||
mergedPdf.Should().NotBeEmpty();
|
||||
|
||||
// Verify merged PDF
|
||||
using var verifyStream = new MemoryStream(mergedPdf);
|
||||
using var verifyProcessor = new DevExpress.Pdf.PdfDocumentProcessor();
|
||||
verifyProcessor.LoadDocument(verifyStream);
|
||||
verifyProcessor.Document.Pages.Count.Should().BeGreaterOrEqualTo(3, "merged PDF should have at least 3 pages");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePdfsAsync_WithPageRanges_MergesSelectedPages()
|
||||
{
|
||||
// Arrange: Merge first page of each PDF
|
||||
using var pdf1 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf"); // Multi-page
|
||||
using var pdf2 = LoadTestPdfAsStream("pdfWithMoreThanOneAttachment.pdf"); // Multi-page
|
||||
var streams = new List<Stream> { pdf1, pdf2 };
|
||||
var pageRanges = new List<string?> { "1", "1" }; // Only first page from each
|
||||
|
||||
// Act
|
||||
byte[] mergedPdf = await _sut.MergePdfsAsync(streams, pageRanges);
|
||||
|
||||
// Assert
|
||||
mergedPdf.Should().NotBeEmpty();
|
||||
|
||||
// Verify merged PDF has exactly 2 pages
|
||||
using var verifyStream = new MemoryStream(mergedPdf);
|
||||
using var verifyProcessor = new DevExpress.Pdf.PdfDocumentProcessor();
|
||||
verifyProcessor.LoadDocument(verifyStream);
|
||||
verifyProcessor.Document.Pages.Count.Should().Be(2, "should have exactly 2 pages (1 from each PDF)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePdfsAsync_WithRangeFormat_MergesSelectedPages()
|
||||
{
|
||||
// Arrange: Merge 2 PDFs, second PDF with page range "1" (first page only)
|
||||
using var pdf1 = LoadTestPdfAsStream("valid.pdf"); // 1 page
|
||||
using var pdf2 = LoadTestPdfAsStream("pdfWithMoreThanOneAttachment.pdf"); // PDF with attachments
|
||||
var streams = new List<Stream> { pdf1, pdf2 };
|
||||
var pageRanges = new List<string?> { null, "1" }; // All pages from first, page 1 only from second
|
||||
|
||||
// Act
|
||||
byte[] mergedPdf = await _sut.MergePdfsAsync(streams, pageRanges);
|
||||
|
||||
// Assert
|
||||
mergedPdf.Should().NotBeEmpty();
|
||||
|
||||
// Verify merged PDF has at least 2 pages (1 from first + 1 from second)
|
||||
using var verifyStream = new MemoryStream(mergedPdf);
|
||||
using var verifyProcessor = new DevExpress.Pdf.PdfDocumentProcessor();
|
||||
verifyProcessor.LoadDocument(verifyStream);
|
||||
verifyProcessor.Document.Pages.Count.Should().BeGreaterOrEqualTo(2, "should have at least 2 pages");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePdfsAsync_SinglePdf_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange: Only one PDF (minimum 2 required)
|
||||
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
|
||||
var streams = new List<Stream> { pdf1 };
|
||||
|
||||
// Act
|
||||
var act = async () => await _sut.MergePdfsAsync(streams);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>()
|
||||
.WithMessage("*at least 2*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePdfsAsync_EmptyStream_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange: One valid PDF + one empty stream
|
||||
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
|
||||
using var emptyStream = new MemoryStream();
|
||||
var streams = new List<Stream> { pdf1, emptyStream };
|
||||
|
||||
// Act
|
||||
var act = async () => await _sut.MergePdfsAsync(streams);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>()
|
||||
.WithMessage("*empty*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePdfsAsync_StreamNotAtBeginning_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange: Valid PDFs but second stream not at Position=0
|
||||
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
|
||||
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
|
||||
pdf2.Position = 10; // Move position away from beginning
|
||||
var streams = new List<Stream> { pdf1, pdf2 };
|
||||
|
||||
// Act
|
||||
var act = async () => await _sut.MergePdfsAsync(streams);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePdfsAsync_InvalidPageRange_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange: Invalid page range format
|
||||
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
|
||||
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
|
||||
var streams = new List<Stream> { pdf1, pdf2 };
|
||||
var pageRanges = new List<string?> { "1", "abc" }; // Invalid format
|
||||
|
||||
// Act
|
||||
var act = async () => await _sut.MergePdfsAsync(streams, pageRanges);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>()
|
||||
.WithMessage("*Invalid page*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePdfsAsync_PageRangeExceedsPageCount_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange: Page range exceeds document page count
|
||||
using var pdf1 = LoadTestPdfAsStream("valid.pdf"); // 1 page
|
||||
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
|
||||
var streams = new List<Stream> { pdf1, pdf2 };
|
||||
var pageRanges = new List<string?> { "1-99", null }; // 99 exceeds pdf1 page count
|
||||
|
||||
// Act
|
||||
var act = async () => await _sut.MergePdfsAsync(streams, pageRanges);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>()
|
||||
.WithMessage("*exceeds document page count*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergePdfsAsync_PageRangesMismatch_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange: Page ranges count doesn't match streams count
|
||||
using var pdf1 = LoadTestPdfAsStream("valid.pdf");
|
||||
using var pdf2 = LoadTestPdfAsStream("pdfWithSwissQRCode.pdf");
|
||||
var streams = new List<Stream> { pdf1, pdf2 };
|
||||
var pageRanges = new List<string?> { "1" }; // Only 1 range for 2 PDFs
|
||||
|
||||
// Act
|
||||
var act = async () => await _sut.MergePdfsAsync(streams, pageRanges);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>()
|
||||
.WithMessage("*must match*");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region AddAnnotationAsync Tests
|
||||
|
||||
[Fact]
|
||||
public async Task AddAnnotationAsync_TextMarkupHighlight_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
var annotationType = Domain.Models.ValueObjects.AnnotationType.TextMarkup;
|
||||
var rectangle = (X1: 100.0, Y1: 100.0, X2: 200.0, Y2: 120.0);
|
||||
var textMarkupStyle = Domain.Models.ValueObjects.TextMarkupStyle.Highlight;
|
||||
|
||||
// Act
|
||||
byte[] result = await _sut.AddAnnotationAsync(
|
||||
pdfStream,
|
||||
annotationType,
|
||||
pageNumber: 1,
|
||||
rectangle,
|
||||
content: "Important text",
|
||||
author: "Test Author",
|
||||
color: "FFFF00", // Yellow
|
||||
textMarkupStyle);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeEmpty();
|
||||
result.Length.Should().BeGreaterThan((int)pdfStream.Length); // Annotation adds bytes
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAnnotationAsync_FreeText_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
var annotationType = Domain.Models.ValueObjects.AnnotationType.FreeText;
|
||||
var rectangle = (X1: 50.0, Y1: 50.0, X2: 150.0, Y2: 100.0);
|
||||
|
||||
// Act
|
||||
byte[] result = await _sut.AddAnnotationAsync(
|
||||
pdfStream,
|
||||
annotationType,
|
||||
pageNumber: 1,
|
||||
rectangle,
|
||||
content: "Free text annotation",
|
||||
author: "John Doe",
|
||||
color: "FF0000"); // Red
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeEmpty();
|
||||
result.Length.Should().BeGreaterThan((int)pdfStream.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAnnotationAsync_StickyNote_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
var annotationType = Domain.Models.ValueObjects.AnnotationType.StickyNote;
|
||||
var rectangle = (X1: 300.0, Y1: 300.0, X2: 320.0, Y2: 320.0);
|
||||
|
||||
// Act
|
||||
byte[] result = await _sut.AddAnnotationAsync(
|
||||
pdfStream,
|
||||
annotationType,
|
||||
pageNumber: 1,
|
||||
rectangle,
|
||||
content: "Please review this section",
|
||||
author: "Reviewer");
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAnnotationAsync_Circle_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
var annotationType = Domain.Models.ValueObjects.AnnotationType.Circle;
|
||||
var rectangle = (X1: 100.0, Y1: 200.0, X2: 200.0, Y2: 300.0);
|
||||
|
||||
// Act
|
||||
byte[] result = await _sut.AddAnnotationAsync(
|
||||
pdfStream,
|
||||
annotationType,
|
||||
pageNumber: 1,
|
||||
rectangle,
|
||||
content: "Circle annotation",
|
||||
color: "00FF00"); // Green
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAnnotationAsync_Square_ReturnsAnnotatedPdf()
|
||||
{
|
||||
// Arrange
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
var annotationType = Domain.Models.ValueObjects.AnnotationType.Square;
|
||||
var rectangle = (X1: 250.0, Y1: 250.0, X2: 350.0, Y2: 350.0);
|
||||
|
||||
// Act
|
||||
byte[] result = await _sut.AddAnnotationAsync(
|
||||
pdfStream,
|
||||
annotationType,
|
||||
pageNumber: 1,
|
||||
rectangle,
|
||||
color: "0000FF"); // Blue
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAnnotationAsync_EmptyStream_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange
|
||||
using var emptyStream = new MemoryStream();
|
||||
|
||||
// Act
|
||||
var act = async () => await _sut.AddAnnotationAsync(
|
||||
emptyStream,
|
||||
Domain.Models.ValueObjects.AnnotationType.FreeText,
|
||||
pageNumber: 1,
|
||||
rectangle: (0, 0, 100, 100),
|
||||
content: "Test");
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>()
|
||||
.WithMessage("*cannot be empty*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAnnotationAsync_StreamNotAtPositionZero_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
pdfStream.Position = 10; // Move stream position
|
||||
|
||||
// Act
|
||||
var act = async () => await _sut.AddAnnotationAsync(
|
||||
pdfStream,
|
||||
Domain.Models.ValueObjects.AnnotationType.Circle,
|
||||
pageNumber: 1,
|
||||
rectangle: (0, 0, 100, 100));
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>()
|
||||
.WithMessage("*must be at position 0*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAnnotationAsync_InvalidPageNumber_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
|
||||
// Act
|
||||
var act = async () => await _sut.AddAnnotationAsync(
|
||||
pdfStream,
|
||||
Domain.Models.ValueObjects.AnnotationType.FreeText,
|
||||
pageNumber: 999, // Exceeds page count
|
||||
rectangle: (0, 0, 100, 100),
|
||||
content: "Test");
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>()
|
||||
.WithMessage("*exceeds document page count*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAnnotationAsync_FreeTextWithoutContent_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
|
||||
// Act
|
||||
var act = async () => await _sut.AddAnnotationAsync(
|
||||
pdfStream,
|
||||
Domain.Models.ValueObjects.AnnotationType.FreeText,
|
||||
pageNumber: 1,
|
||||
rectangle: (0, 0, 100, 100),
|
||||
content: null); // Missing required content
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>()
|
||||
.WithMessage("*requires content*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAnnotationAsync_StickyNoteWithoutContent_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
|
||||
// Act
|
||||
var act = async () => await _sut.AddAnnotationAsync(
|
||||
pdfStream,
|
||||
Domain.Models.ValueObjects.AnnotationType.StickyNote,
|
||||
pageNumber: 1,
|
||||
rectangle: (0, 0, 20, 20),
|
||||
content: ""); // Empty content
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>()
|
||||
.WithMessage("*requires content*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAnnotationAsync_TextMarkupWithoutStyle_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
|
||||
// Act
|
||||
var act = async () => await _sut.AddAnnotationAsync(
|
||||
pdfStream,
|
||||
Domain.Models.ValueObjects.AnnotationType.TextMarkup,
|
||||
pageNumber: 1,
|
||||
rectangle: (0, 0, 100, 100),
|
||||
textMarkupStyle: null); // Missing required style
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>()
|
||||
.WithMessage("*requires textMarkupStyle*");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddAnnotationAsync_InvalidColorFormat_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange
|
||||
using var pdfStream = LoadTestPdfAsStream("valid.pdf");
|
||||
|
||||
// Act
|
||||
var act = async () => await _sut.AddAnnotationAsync(
|
||||
pdfStream,
|
||||
Domain.Models.ValueObjects.AnnotationType.Circle,
|
||||
pageNumber: 1,
|
||||
rectangle: (0, 0, 100, 100),
|
||||
color: "INVALID"); // Invalid hex format
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<BadRequestException>()
|
||||
.WithMessage("*Invalid color format*");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Reflection;
|
||||
using DocumentOperator.Application.Common.Interfaces;
|
||||
using DocumentOperator.Domain.Common.Exceptions;
|
||||
using DocumentOperator.Domain.Exceptions;
|
||||
using DocumentOperator.Infrastructure.Services.QrCodeProcessing;
|
||||
using FluentAssertions;
|
||||
|
||||
@@ -24,16 +23,16 @@ public class DevExpressSwissQrCodeProcessorTests
|
||||
#region Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// Loads a test PDF from embedded resources.
|
||||
/// Loads a test PDF from embedded resources as a Stream.
|
||||
/// </summary>
|
||||
/// <param name="filename">Name of the PDF file (e.g., "pdfWithSwissQRCode.pdf")</param>
|
||||
/// <returns>PDF content as byte array</returns>
|
||||
private static byte[] LoadTestPdf(string filename)
|
||||
/// <returns>PDF content as MemoryStream</returns>
|
||||
private static Stream LoadTestPdf(string filename)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceName = $"DocumentOperator.Tests.TestData.Pdfs.{filename}";
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
|
||||
if (stream == null)
|
||||
{
|
||||
@@ -42,9 +41,11 @@ public class DevExpressSwissQrCodeProcessorTests
|
||||
$"Available resources: {string.Join(", ", assembly.GetManifestResourceNames())}");
|
||||
}
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
// Copy to MemoryStream so caller can reuse/seek
|
||||
var memoryStream = new MemoryStream();
|
||||
stream.CopyTo(memoryStream);
|
||||
return memoryStream.ToArray();
|
||||
memoryStream.Position = 0; // Reset position for reading
|
||||
return memoryStream;
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -55,10 +56,10 @@ public class DevExpressSwissQrCodeProcessorTests
|
||||
public async Task ExtractSwissQrCodeAsync_PdfWithSwissQrCode_ReturnsQrCodeData()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = LoadTestPdf("pdfWithSwissQRCode.pdf");
|
||||
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
|
||||
|
||||
// Act
|
||||
var (bill, rawLines) = await _sut.ExtractSwissQrCodeAsync(pdfBytes);
|
||||
var (bill, rawLines) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
|
||||
|
||||
// Assert
|
||||
bill.Should().NotBeNull("PDF contains a Swiss QR Code");
|
||||
@@ -73,10 +74,10 @@ public class DevExpressSwissQrCodeProcessorTests
|
||||
public async Task ExtractSwissQrCodeAsync_PdfWithSwissQrCode_ReturnsValidIban()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = LoadTestPdf("pdfWithSwissQRCode.pdf");
|
||||
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
|
||||
|
||||
// Act
|
||||
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfBytes);
|
||||
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
|
||||
|
||||
// Assert
|
||||
bill.Account.Should().MatchRegex(@"^CH\d{2}[A-Z0-9]{17}$",
|
||||
@@ -87,10 +88,10 @@ public class DevExpressSwissQrCodeProcessorTests
|
||||
public async Task ExtractSwissQrCodeAsync_PdfWithSwissQrCode_ReturnsCurrency()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = LoadTestPdf("pdfWithSwissQRCode.pdf");
|
||||
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
|
||||
|
||||
// Act
|
||||
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfBytes);
|
||||
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
|
||||
|
||||
// Assert
|
||||
bill.Currency.Should().BeOneOf("CHF", "EUR",
|
||||
@@ -101,10 +102,10 @@ public class DevExpressSwissQrCodeProcessorTests
|
||||
public async Task ExtractSwissQrCodeAsync_PdfWithoutQrCode_ThrowsSwissQrCodeNotFoundException()
|
||||
{
|
||||
// Arrange: valid.pdf doesn't contain a Swiss QR Code
|
||||
byte[] pdfBytes = LoadTestPdf("valid.pdf");
|
||||
using var pdfStream = LoadTestPdf("valid.pdf");
|
||||
|
||||
// Act & Assert
|
||||
var act = async () => await _sut.ExtractSwissQrCodeAsync(pdfBytes);
|
||||
var act = async () => await _sut.ExtractSwissQrCodeAsync(pdfStream);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>()
|
||||
.WithMessage("*No valid Swiss QR Code found*");
|
||||
@@ -113,11 +114,11 @@ public class DevExpressSwissQrCodeProcessorTests
|
||||
[Fact]
|
||||
public async Task ExtractSwissQrCodeAsync_EmptyPdf_ThrowsException()
|
||||
{
|
||||
// Arrange: Empty byte array
|
||||
byte[] emptyPdfBytes = [];
|
||||
// Arrange: Empty stream
|
||||
using var emptyStream = new MemoryStream();
|
||||
|
||||
// Act & Assert
|
||||
var act = async () => await _sut.ExtractSwissQrCodeAsync(emptyPdfBytes);
|
||||
var act = async () => await _sut.ExtractSwissQrCodeAsync(emptyStream);
|
||||
|
||||
await act.Should().ThrowAsync<Exception>()
|
||||
.Where(ex => ex is ArgumentException);
|
||||
@@ -127,26 +128,39 @@ public class DevExpressSwissQrCodeProcessorTests
|
||||
public async Task ExtractSwissQrCodeAsync_NullInput_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange
|
||||
byte[] nullPdfBytes = null!;
|
||||
Stream nullStream = null!;
|
||||
|
||||
// Act & Assert
|
||||
var act = async () => await _sut.ExtractSwissQrCodeAsync(nullPdfBytes);
|
||||
var act = async () => await _sut.ExtractSwissQrCodeAsync(nullStream);
|
||||
|
||||
await act.Should().ThrowAsync<NullReferenceException>();
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractSwissQrCodeAsync_InvalidPdfBytes_ThrowsPdfProcessingException()
|
||||
{
|
||||
// Arrange: Random bytes that are not a valid PDF
|
||||
byte[] invalidPdfBytes = "This is not a PDF file"u8.ToArray();
|
||||
using var invalidStream = new MemoryStream("This is not a PDF file"u8.ToArray());
|
||||
|
||||
// Act & Assert
|
||||
var act = async () => await _sut.ExtractSwissQrCodeAsync(invalidPdfBytes);
|
||||
var act = async () => await _sut.ExtractSwissQrCodeAsync(invalidStream);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtractSwissQrCodeAsync_StreamNotAtBeginning_ThrowsBadRequestException()
|
||||
{
|
||||
// Arrange: Valid PDF but stream position is not at 0
|
||||
using var pdfStream = LoadTestPdf("valid.pdf");
|
||||
pdfStream.Position = 10; // Move position away from beginning
|
||||
|
||||
// Act & Assert
|
||||
var act = async () => await _sut.ExtractSwissQrCodeAsync(pdfStream);
|
||||
|
||||
await act.Should().ThrowAsync<BadRequestException>();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Swiss QR Code Content Validation Tests
|
||||
@@ -155,10 +169,10 @@ public class DevExpressSwissQrCodeProcessorTests
|
||||
public async Task ExtractSwissQrCodeAsync_ValidQrCode_ParsesCreditorInformation()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = LoadTestPdf("pdfWithSwissQRCode.pdf");
|
||||
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
|
||||
|
||||
// Act
|
||||
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfBytes);
|
||||
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
|
||||
|
||||
// Assert
|
||||
bill.Creditor.Should().NotBeNull("Creditor information is required");
|
||||
@@ -170,10 +184,10 @@ public class DevExpressSwissQrCodeProcessorTests
|
||||
public async Task ExtractSwissQrCodeAsync_ValidQrCode_ParsesDebtorInformationIfPresent()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = LoadTestPdf("pdfWithSwissQRCode.pdf");
|
||||
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
|
||||
|
||||
// Act
|
||||
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfBytes);
|
||||
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
|
||||
|
||||
// Assert
|
||||
// Debtor information is OPTIONAL in Swiss QR Bill Standard 2.0
|
||||
@@ -190,10 +204,10 @@ public class DevExpressSwissQrCodeProcessorTests
|
||||
public async Task ExtractSwissQrCodeAsync_ValidQrCode_ParsesAmountIfPresent()
|
||||
{
|
||||
// Arrange
|
||||
byte[] pdfBytes = LoadTestPdf("pdfWithSwissQRCode.pdf");
|
||||
using var pdfStream = LoadTestPdf("pdfWithSwissQRCode.pdf");
|
||||
|
||||
// Act
|
||||
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfBytes);
|
||||
var (bill, _) = await _sut.ExtractSwissQrCodeAsync(pdfStream);
|
||||
|
||||
// Assert
|
||||
// Amount is OPTIONAL in Swiss QR Bill (can be 0.00 or null for payment slips)
|
||||
|
||||
321
FEATURE_7_PLAN.md
Normal file
321
FEATURE_7_PLAN.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# Feature 7 Plan: PDF Stamp Operation
|
||||
|
||||
## Overview
|
||||
**Priority:** Phase 2, Priority 6
|
||||
**Endpoint:** `POST /api/pdf/operations/stamp`
|
||||
**Purpose:** Add text/image stamps to PDF documents (watermarks, confidential marks, approval stamps)
|
||||
|
||||
---
|
||||
|
||||
## Requirements (from CONTROLLER_ENDPOINTS.md)
|
||||
|
||||
### Endpoint Specification
|
||||
- **Route:** `/api/pdf/operations/stamp`
|
||||
- **Method:** POST
|
||||
- **Input formats:**
|
||||
- Multipart/form-data (PDF file + stamp parameters)
|
||||
- JSON (Base64-encoded PDF + stamp parameters)
|
||||
- **Output:** Stamped PDF file (application/pdf)
|
||||
|
||||
### Stamp Types
|
||||
1. **Text Stamp:** Custom text with font, size, color, rotation
|
||||
2. **Image Stamp:** PNG/JPEG image overlay
|
||||
3. **Predefined Stamps:** "CONFIDENTIAL", "APPROVED", "DRAFT", "VOID" (optional)
|
||||
|
||||
### Stamp Parameters
|
||||
- **Position:** X, Y coordinates (with Origin support: BottomLeft/TopLeft)
|
||||
- **Size:** Width, Height (optional if using image natural size)
|
||||
- **Rotation:** Angle in degrees (0-360)
|
||||
- **Opacity:** 0.0 (transparent) to 1.0 (opaque)
|
||||
- **Page range:** Single page, multiple pages, or "all pages"
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan (7 Systematic Commits)
|
||||
|
||||
### Commit 1: Domain Layer
|
||||
**File:** `DocumentOperator.Domain/Models/ValueObjects/StampType.cs`
|
||||
```csharp
|
||||
public enum StampType
|
||||
{
|
||||
Text,
|
||||
Image,
|
||||
Predefined
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `DocumentOperator.Domain/Models/ValueObjects/PredefinedStampType.cs`
|
||||
```csharp
|
||||
public enum PredefinedStampType
|
||||
{
|
||||
Confidential,
|
||||
Approved,
|
||||
Draft,
|
||||
Void,
|
||||
ForReview
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `DocumentOperator.Domain/Models/ValueObjects/StampPlacement.cs`
|
||||
```csharp
|
||||
public enum StampPlacement
|
||||
{
|
||||
Foreground, // On top of content
|
||||
Background // Behind content
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Commit 2: Infrastructure Interface
|
||||
**File:** `DocumentOperator.Application/Common/Interfaces/IPdfProcessor.cs`
|
||||
|
||||
Add method:
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Adds a stamp (text or image) to specified pages of a PDF document.
|
||||
/// </summary>
|
||||
Task<byte[]> AddStampAsync(
|
||||
Stream pdfStream,
|
||||
StampType stampType,
|
||||
int[]? pageNumbers, // null = all pages
|
||||
(double X, double Y) position,
|
||||
(double Width, double Height)? size = null, // null = auto-size for image
|
||||
AnnotationOrigin origin = AnnotationOrigin.BottomLeft,
|
||||
string? text = null, // Required for Text stamp
|
||||
string? fontName = null, // Default: Arial
|
||||
double? fontSize = null, // Default: 12
|
||||
string? color = null, // Hex color, default: "000000" (black)
|
||||
double? opacity = null, // 0.0-1.0, default: 0.5
|
||||
double? rotation = null, // Degrees, default: 0
|
||||
StampPlacement placement = StampPlacement.Foreground,
|
||||
byte[]? imageBytes = null, // Required for Image stamp
|
||||
PredefinedStampType? predefinedType = null); // Required for Predefined stamp
|
||||
```
|
||||
|
||||
**Validation rules:**
|
||||
- `text` required if `stampType == Text`
|
||||
- `imageBytes` required if `stampType == Image`
|
||||
- `predefinedType` required if `stampType == Predefined`
|
||||
- `pageNumbers` can be null (all pages), empty array not allowed
|
||||
- `opacity` must be 0.0-1.0
|
||||
- `rotation` must be 0-360
|
||||
|
||||
---
|
||||
|
||||
### Commit 3: Infrastructure Implementation
|
||||
**File:** `DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs`
|
||||
|
||||
**DevExpress API:**
|
||||
- Use `PdfGraphics` to draw on page
|
||||
- `PdfGraphics.DrawString()` for text stamps
|
||||
- `PdfGraphics.DrawImage()` for image stamps
|
||||
- Set opacity via `PdfBrush.Color` alpha channel
|
||||
- Apply rotation via `PdfGraphics.RotateTransform()`
|
||||
|
||||
**Implementation strategy:**
|
||||
1. Load PDF
|
||||
2. Validate page numbers against document page count
|
||||
3. For each target page:
|
||||
- Get `PdfGraphics` from `PdfDocumentProcessor.CreateGraphics()`
|
||||
- Convert coordinates if `origin == TopLeft`
|
||||
- Apply rotation transform
|
||||
- Set opacity via brush alpha
|
||||
- Draw text/image
|
||||
- Dispose graphics
|
||||
4. Save to byte array
|
||||
|
||||
**Private helper methods:**
|
||||
- `AddTextStamp(PdfGraphics graphics, ...)`
|
||||
- `AddImageStamp(PdfGraphics graphics, byte[] imageBytes, ...)`
|
||||
- `AddPredefinedStamp(PdfGraphics graphics, PredefinedStampType type, ...)` (delegates to AddTextStamp with predefined text/style)
|
||||
- `CalculatePageNumbers(int totalPages, int[]? requestedPages)` → returns array of zero-based indices
|
||||
|
||||
---
|
||||
|
||||
### Commit 4: Infrastructure Unit Tests
|
||||
**File:** `DocumentOperator.Tests/Unit/Infrastructure/Services/PdfProcessing/DevExpressPdfProcessorTests.cs`
|
||||
|
||||
**Tests (10-12):**
|
||||
1. `AddStampAsync_TextStampSinglePage_ReturnsStampedPdf`
|
||||
2. `AddStampAsync_TextStampAllPages_ReturnsStampedPdf`
|
||||
3. `AddStampAsync_ImageStampWithRotation_ReturnsStampedPdf`
|
||||
4. `AddStampAsync_PredefinedStampConfidential_ReturnsStampedPdf`
|
||||
5. `AddStampAsync_WithOpacity_ReturnsStampedPdf`
|
||||
6. `AddStampAsync_TopLeftOrigin_ReturnsStampedPdf`
|
||||
7. `AddStampAsync_EmptyStream_ThrowsBadRequestException`
|
||||
8. `AddStampAsync_InvalidPageNumber_ThrowsBadRequestException`
|
||||
9. `AddStampAsync_TextStampWithoutText_ThrowsBadRequestException`
|
||||
10. `AddStampAsync_ImageStampWithoutBytes_ThrowsBadRequestException`
|
||||
11. `AddStampAsync_InvalidOpacity_ThrowsBadRequestException`
|
||||
12. `AddStampAsync_InvalidRotation_ThrowsBadRequestException`
|
||||
|
||||
---
|
||||
|
||||
### Commit 5: Application Layer
|
||||
**File:** `DocumentOperator.Application/AddStamp/AddStampCommand.cs`
|
||||
|
||||
**Command/Handler/Validator merged:**
|
||||
```csharp
|
||||
public record AddStampCommand : IRequest<byte[]>
|
||||
{
|
||||
public required Stream PdfStream { get; init; }
|
||||
public required StampType StampType { get; init; }
|
||||
public int[]? PageNumbers { get; init; } // null = all pages
|
||||
public required (double X, double Y) Position { get; init; }
|
||||
public (double Width, double Height)? Size { get; init; }
|
||||
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
|
||||
public string? Text { get; init; }
|
||||
public string? FontName { get; init; }
|
||||
public double? FontSize { get; init; }
|
||||
public string? Color { get; init; }
|
||||
public double? Opacity { get; init; }
|
||||
public double? Rotation { get; init; }
|
||||
public StampPlacement Placement { get; init; } = StampPlacement.Foreground;
|
||||
public byte[]? ImageBytes { get; init; }
|
||||
public PredefinedStampType? PredefinedType { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
**Validator:**
|
||||
- Stream not null
|
||||
- Text required if StampType == Text
|
||||
- ImageBytes required if StampType == Image
|
||||
- PredefinedType required if StampType == Predefined
|
||||
- Color regex: `^[0-9A-Fa-f]{6}$`
|
||||
- Opacity: 0.0-1.0
|
||||
- Rotation: 0-360
|
||||
- FontSize > 0 if provided
|
||||
|
||||
---
|
||||
|
||||
### Commit 6: API Endpoints + DTOs
|
||||
**File:** `DocumentOperator.API/Controllers/PdfOperationsController.cs`
|
||||
|
||||
**Two endpoints:**
|
||||
1. **Multipart:** `POST /api/pdf/operations/stamp` (multipart/form-data)
|
||||
2. **JSON:** `POST /api/pdf/operations/stamp` (application/json)
|
||||
|
||||
**DTOs:**
|
||||
```csharp
|
||||
public class AddStampMultipartRequest
|
||||
{
|
||||
public required IFormFile File { get; set; }
|
||||
public required StampType StampType { get; set; }
|
||||
public int[]? PageNumbers { get; set; } // Comma-separated in form: "1,3,5"
|
||||
public required double X { get; set; }
|
||||
public required double Y { get; set; }
|
||||
public double? Width { get; set; }
|
||||
public double? Height { get; set; }
|
||||
public AnnotationOrigin Origin { get; set; } = AnnotationOrigin.BottomLeft;
|
||||
public string? Text { get; set; }
|
||||
public string? FontName { get; set; }
|
||||
public double? FontSize { get; set; }
|
||||
public string? Color { get; set; }
|
||||
public double? Opacity { get; set; }
|
||||
public double? Rotation { get; set; }
|
||||
public StampPlacement Placement { get; set; } = StampPlacement.Foreground;
|
||||
public IFormFile? ImageFile { get; set; } // Optional image for Image stamp
|
||||
public PredefinedStampType? PredefinedType { get; set; }
|
||||
}
|
||||
|
||||
public record AddStampBase64Request
|
||||
{
|
||||
public required string Base64Pdf { get; init; }
|
||||
public required StampType StampType { get; init; }
|
||||
public int[]? PageNumbers { get; init; }
|
||||
public required double X { get; init; }
|
||||
public required double Y { get; init; }
|
||||
public double? Width { get; init; }
|
||||
public double? Height { get; init; }
|
||||
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
|
||||
public string? Text { get; init; }
|
||||
public string? FontName { get; init; }
|
||||
public double? FontSize { get; init; }
|
||||
public string? Color { get; init; }
|
||||
public double? Opacity { get; init; }
|
||||
public double? Rotation { get; init; }
|
||||
public StampPlacement Placement { get; init; } = StampPlacement.Foreground;
|
||||
public string? Base64Image { get; init; } // Base64-encoded image bytes
|
||||
public PredefinedStampType? PredefinedType { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
**Endpoint names:**
|
||||
- `Name = "AddStampFromFile"`
|
||||
- `Name = "AddStampFromBase64"`
|
||||
|
||||
---
|
||||
|
||||
### Commit 7: Integration Tests + Documentation
|
||||
**File:** `DocumentOperator.Tests/Integration/API/PdfOperationsControllerTests.cs`
|
||||
|
||||
**Tests (8-10):**
|
||||
1. `AddStampFromFile_TextStampSinglePage_ReturnsStampedPdf`
|
||||
2. `AddStampFromBase64_TextStampAllPages_ReturnsStampedPdf`
|
||||
3. `AddStampFromFile_ImageStamp_ReturnsStampedPdf`
|
||||
4. `AddStampFromBase64_PredefinedStamp_ReturnsStampedPdf`
|
||||
5. `AddStampFromBase64_WithRotationAndOpacity_ReturnsStampedPdf`
|
||||
6. `AddStampFromBase64_InvalidBase64_Returns400`
|
||||
7. `AddStampFromBase64_TextStampWithoutText_Returns400`
|
||||
8. `AddStampFromBase64_ImageStampWithoutImage_Returns400`
|
||||
9. `AddStampFromBase64_InvalidPageNumber_Returns400`
|
||||
10. `AddStampFromBase64_InvalidOpacity_Returns400`
|
||||
|
||||
**Update AGENTS.md:**
|
||||
- Test count: 101 → ~120 passed
|
||||
- PdfOperationsController status: 2/3 → 3/3 endpoints (merge + annotate + stamp ALL DONE)
|
||||
- Add Feature 7 to test breakdown
|
||||
|
||||
---
|
||||
|
||||
## DevExpress API References
|
||||
|
||||
**Key classes:**
|
||||
- `PdfDocumentProcessor.CreateGraphics(int pageIndex)` → `PdfGraphics`
|
||||
- `PdfGraphics.DrawString(string text, PdfFont font, PdfBrush brush, RectangleF bounds)`
|
||||
- `PdfGraphics.DrawImage(Image image, RectangleF bounds)`
|
||||
- `PdfGraphics.RotateTransform(float angle)`
|
||||
- `PdfGraphics.SetTransparency(float opacity)` or use `Color.FromArgb(alpha, r, g, b)`
|
||||
- `PdfFont.Create(string fontName, float fontSize)`
|
||||
- `PdfBrush.Create(Color color)`
|
||||
|
||||
**Coordinate system:**
|
||||
- Same as annotations: BottomLeft origin by default
|
||||
- Need to convert if user specifies TopLeft
|
||||
|
||||
**Stamp placement:**
|
||||
- Foreground: Draw after page content (`CreateGraphics` with `PdfGraphicsContentStreamType.Foreground`)
|
||||
- Background: Draw before page content (`CreateGraphics` with `PdfGraphicsContentStreamType.Background`)
|
||||
|
||||
---
|
||||
|
||||
## Potential Challenges
|
||||
|
||||
1. **Image format support:** DevExpress may require specific image formats (PNG, JPEG). Need to validate.
|
||||
2. **Font availability:** Custom fonts may not be available on server. Default to Arial.
|
||||
3. **Text wrapping:** If text is too long, may overflow stamp bounds. Truncate or wrap?
|
||||
4. **Performance:** Adding stamp to all pages of large PDF (500+ pages) may be slow. Consider async/streaming.
|
||||
|
||||
---
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
- **Complexity:** Medium (similar to annotation, but with graphics drawing)
|
||||
- **Time:** 4-6 hours (7 commits)
|
||||
- **Test coverage:** ~20 tests (12 unit + 10 integration)
|
||||
- **Expected test count after completion:** ~120 passed, 7 skipped
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ All 7 commits completed systematically
|
||||
✅ Build: 0 errors, 0 warnings (ignore DevExpress trial warnings)
|
||||
✅ Tests: ~120 passed, 7 skipped, 0 failed
|
||||
✅ Swagger: Both stamp endpoints visible and testable
|
||||
✅ Documentation: AGENTS.md updated with Feature 7 status
|
||||
|
||||
---
|
||||
|
||||
**Ready to start when you approve!** 🎉
|
||||
Reference in New Issue
Block a user