From cd50d45bd5f95ff6fd3eee08a9e7169a1003a7d9 Mon Sep 17 00:00:00 2001 From: TekH Date: Thu, 9 Jul 2026 12:59:24 +0200 Subject: [PATCH] feat: Add PDF/A validation endpoint (Feature 3) Domain layer: - PdfAMetadata value object (isValid, pdfVersion, pageCount, encrypted, pdfaVersion, pdfaCompliant, errors, warnings) Infrastructure layer: - IPdfProcessor.ValidatePdfAAsync() interface method - DevExpressPdfProcessor.ValidatePdfAAsync() implementation - DetectEncryption() - scans PDF raw data for /Encrypt keyword - DetectPdfAConformance() - parses XMP metadata (pdfaid:part, pdfaid:conformance) - Validation: encrypted PDF cannot be PDF/A compliant Application layer: - ValidatePdfAQuery + ValidatePdfAQueryHandler (co-located) - ValidatePdfAQueryValidator (FluentValidation: PdfBytes XOR Base64Pdf) - PdfAValidationResult DTO - AutoMapper: PdfAMetadata -> PdfAValidationResult API layer: - PdfValidationController.ValidatePdfAFromFile() (multipart/form-data) - PdfValidationController.ValidatePdfAFromBase64() (application/json) - XML documentation with response codes Result: - POST /api/pdf/validation/validate-pdfa (both multipart and JSON) - Returns: conformance level, errors, warnings - Build: 0 errors, 4 warnings (DevExpress eval) - Tests: 20/20 passing Next: Integration tests + Swagger test case --- .../Controllers/PdfValidationController.cs | 65 +++++++++ .../Common/DTOs/PdfAValidationResult.cs | 52 +++++++ .../Common/Interfaces/IPdfProcessor.cs | 10 ++ .../Common/Mapping/MappingProfile.cs | 4 + .../ValidatePdfA/Queries/ValidatePdfAQuery.cs | 45 +++++++ .../Validators/ValidatePdfAQueryValidator.cs | 18 +++ .../Models/ValueObjects/PdfAMetadata.cs | 47 +++++++ .../PdfProcessing/DevExpressPdfProcessor.cs | 127 +++++++++++++++++- 8 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 DocumentOperator.Application/Common/DTOs/PdfAValidationResult.cs create mode 100644 DocumentOperator.Application/ValidatePdfA/Queries/ValidatePdfAQuery.cs create mode 100644 DocumentOperator.Application/ValidatePdfA/Validators/ValidatePdfAQueryValidator.cs create mode 100644 DocumentOperator.Domain/Models/ValueObjects/PdfAMetadata.cs diff --git a/DocumentOperator.API/Controllers/PdfValidationController.cs b/DocumentOperator.API/Controllers/PdfValidationController.cs index 1b4a6fd..b6a63cc 100644 --- a/DocumentOperator.API/Controllers/PdfValidationController.cs +++ b/DocumentOperator.API/Controllers/PdfValidationController.cs @@ -1,5 +1,6 @@ using DocumentOperator.Application.Common.DTOs; using DocumentOperator.Application.ValidatePdf.Queries; +using DocumentOperator.Application.ValidatePdfA.Queries; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -76,4 +77,68 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase return Ok(result); } + + /// + /// Validates a PDF/A document and checks conformance level (multipart/form-data) + /// + /// PDF file to validate + /// Cancellation token + /// PDF/A metadata (conformance level, errors, warnings) + /// PDF/A validation completed, results returned + /// Invalid PDF or file format + /// Internal server error during validation + [HttpPost("validate-pdfa")] + [Consumes("multipart/form-data")] + [ProducesResponseType(typeof(PdfAValidationResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task ValidatePdfAFromFile( + IFormFile file, + CancellationToken cancellationToken) + { + if (file == null || file.Length == 0) + { + return BadRequest(new ProblemDetails + { + Title = "Invalid file", + Detail = "File is required and cannot be empty", + Status = StatusCodes.Status400BadRequest + }); + } + + // Convert IFormFile to byte array + using var memoryStream = new MemoryStream(); + await file.CopyToAsync(memoryStream, cancellationToken); + byte[] pdfBytes = memoryStream.ToArray(); + + // Direct pass-through to MediatR + var query = new ValidatePdfAQuery { PdfBytes = pdfBytes }; + var result = await Mediator.Send(query, cancellationToken); + + return Ok(result); + } + + /// + /// Validates a PDF/A document and checks conformance level (Base64 JSON) + /// + /// PDF as Base64 string + /// Cancellation token + /// PDF/A metadata (conformance level, errors, warnings) + /// PDF/A validation completed, results returned + /// Invalid PDF or Base64 format + /// Internal server error during validation + [HttpPost("validate-pdfa")] + [Consumes("application/json")] + [ProducesResponseType(typeof(PdfAValidationResult), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + public async Task ValidatePdfAFromBase64( + [FromBody] ValidatePdfAQuery query, + CancellationToken cancellationToken) + { + // Direct pass-through to MediatR + var result = await Mediator.Send(query, cancellationToken); + + return Ok(result); + } } diff --git a/DocumentOperator.Application/Common/DTOs/PdfAValidationResult.cs b/DocumentOperator.Application/Common/DTOs/PdfAValidationResult.cs new file mode 100644 index 0000000..0c381c0 --- /dev/null +++ b/DocumentOperator.Application/Common/DTOs/PdfAValidationResult.cs @@ -0,0 +1,52 @@ +namespace DocumentOperator.Application.Common.DTOs; + +/// +/// PDF/A validation result including conformance level and validation errors/warnings +/// +public record PdfAValidationResult +{ + /// + /// Whether the PDF is valid (no errors) + /// + public bool IsValid { get; init; } + + /// + /// PDF version (e.g., "1.4", "1.7") + /// + public string PdfVersion { get; init; } = string.Empty; + + /// + /// Number of pages in the PDF + /// + public int PageCount { get; init; } + + /// + /// File size in bytes + /// + public long FileSize { get; init; } + + /// + /// Whether the PDF is encrypted + /// + public bool Encrypted { get; init; } + + /// + /// PDF/A version (e.g., "PDF/A-1b", "PDF/A-2a", "PDF/A-3u") or null if not PDF/A compliant + /// + public string? PdfAVersion { get; init; } + + /// + /// Whether the PDF conforms to PDF/A standard + /// + public bool PdfACompliant { get; init; } + + /// + /// Validation errors (e.g., "PDF/A documents cannot be encrypted") + /// + public IReadOnlyList Errors { get; init; } = []; + + /// + /// Validation warnings (e.g., "Manual verification recommended: All fonts must be embedded") + /// + public IReadOnlyList Warnings { get; init; } = []; +} diff --git a/DocumentOperator.Application/Common/Interfaces/IPdfProcessor.cs b/DocumentOperator.Application/Common/Interfaces/IPdfProcessor.cs index 7b55072..bbbb224 100644 --- a/DocumentOperator.Application/Common/Interfaces/IPdfProcessor.cs +++ b/DocumentOperator.Application/Common/Interfaces/IPdfProcessor.cs @@ -13,4 +13,14 @@ public interface IPdfProcessor /// Thrown when PDF is corrupted or cannot be processed /// Task ValidateAsync(byte[] pdfBytes); + + /// + /// Validates a PDF/A document and checks conformance level. + /// + /// PDF content as byte array + /// PDF/A metadata including conformance level and validation errors/warnings + /// + /// Thrown when PDF is corrupted or cannot be processed + /// + Task ValidatePdfAAsync(byte[] pdfBytes); } \ No newline at end of file diff --git a/DocumentOperator.Application/Common/Mapping/MappingProfile.cs b/DocumentOperator.Application/Common/Mapping/MappingProfile.cs index f1711d3..0e6b605 100644 --- a/DocumentOperator.Application/Common/Mapping/MappingProfile.cs +++ b/DocumentOperator.Application/Common/Mapping/MappingProfile.cs @@ -15,6 +15,10 @@ public class MappingProfile : Profile // PdfMetadata -> PdfValidationResult CreateMap(); + // PdfAMetadata -> PdfAValidationResult + CreateMap() + .ForMember(dest => dest.FileSize, opt => opt.MapFrom(src => src.FileSizeBytes)); + // SwissQrCodeData -> SwissQrCodeDataDto CreateMap(); diff --git a/DocumentOperator.Application/ValidatePdfA/Queries/ValidatePdfAQuery.cs b/DocumentOperator.Application/ValidatePdfA/Queries/ValidatePdfAQuery.cs new file mode 100644 index 0000000..c23445d --- /dev/null +++ b/DocumentOperator.Application/ValidatePdfA/Queries/ValidatePdfAQuery.cs @@ -0,0 +1,45 @@ +using AutoMapper; +using DocumentOperator.Application.Common.DTOs; +using DocumentOperator.Application.Common.Interfaces; +using MediatR; + +namespace DocumentOperator.Application.ValidatePdfA.Queries; + +/// +/// Query for PDF/A validation (supports both byte array and Base64 input) +/// +public record ValidatePdfAQuery : IRequest +{ + /// + /// PDF as byte array (direct upload) + /// + public byte[]? PdfBytes { get; init; } + + /// + /// PDF as Base64 string (API clients) + /// + public string? Base64Pdf { get; init; } +} + +/// +/// Handler for ValidatePdfAQuery +/// Orchestrates PDF/A validation using IPdfProcessor and AutoMapper +/// +public class ValidatePdfAQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper) + : IRequestHandler +{ + /// + /// Validates PDF/A and returns metadata with conformance level + /// + public async Task Handle(ValidatePdfAQuery request, CancellationToken cancellationToken) + { + // Use byte[] if available, otherwise convert Base64 + byte[] pdfBytes = request.PdfBytes ?? Convert.FromBase64String(request.Base64Pdf!); + + // Call DevExpress service (can throw PdfProcessingException) + var metadata = await PdfProcessor.ValidatePdfAAsync(pdfBytes); + + // Map domain entity to DTO using AutoMapper + return Mapper.Map(metadata); + } +} diff --git a/DocumentOperator.Application/ValidatePdfA/Validators/ValidatePdfAQueryValidator.cs b/DocumentOperator.Application/ValidatePdfA/Validators/ValidatePdfAQueryValidator.cs new file mode 100644 index 0000000..bdca26e --- /dev/null +++ b/DocumentOperator.Application/ValidatePdfA/Validators/ValidatePdfAQueryValidator.cs @@ -0,0 +1,18 @@ +using FluentValidation; + +namespace DocumentOperator.Application.ValidatePdfA.Validators; + +/// +/// Validator for ValidatePdfAQuery +/// Ensures exactly ONE input format is provided (PdfBytes XOR Base64Pdf) +/// +public class ValidatePdfAQueryValidator : AbstractValidator +{ + 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"); + } +} diff --git a/DocumentOperator.Domain/Models/ValueObjects/PdfAMetadata.cs b/DocumentOperator.Domain/Models/ValueObjects/PdfAMetadata.cs new file mode 100644 index 0000000..9399f24 --- /dev/null +++ b/DocumentOperator.Domain/Models/ValueObjects/PdfAMetadata.cs @@ -0,0 +1,47 @@ +namespace DocumentOperator.Domain.Models.ValueObjects; + +/// +/// PDF/A validation metadata including conformance level and validation errors/warnings +/// +public sealed class PdfAMetadata +{ + public bool IsValid { get; } + public string PdfVersion { get; } + public int PageCount { get; } + public long FileSizeBytes { get; } + public bool Encrypted { get; } + public string? PdfAVersion { get; } + public bool PdfACompliant { get; } + public IReadOnlyList Errors { get; } + public IReadOnlyList Warnings { get; } + + // Computed property + public double FileSizeMB => FileSizeBytes / 1024.0 / 1024.0; + + public PdfAMetadata( + bool isValid, + string pdfVersion, + int pageCount, + long fileSizeBytes, + bool encrypted, + string? pdfaVersion, + bool pdfaCompliant, + IReadOnlyList errors, + IReadOnlyList warnings) + { + IsValid = isValid; + PdfVersion = pdfVersion; + PageCount = pageCount; + FileSizeBytes = fileSizeBytes; + Encrypted = encrypted; + PdfAVersion = pdfaVersion; + PdfACompliant = pdfaCompliant; + Errors = errors; + Warnings = warnings; + } + + public override string ToString() + { + return $"PDF/A: {PdfAVersion ?? "None"}, {PageCount} pages, {FileSizeMB:F2} MB, Compliant: {PdfACompliant}"; + } +} diff --git a/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs b/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs index 838286d..e688818 100644 --- a/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs +++ b/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs @@ -1,6 +1,7 @@ using DevExpress.Pdf; using DocumentOperator.Application.Common.Interfaces; using DocumentOperator.Domain.Common.Exceptions; +using DocumentOperator.Domain.Models.ValueObjects; namespace DocumentOperator.Infrastructure.Services.PdfProcessing; @@ -16,7 +17,7 @@ public class DevExpressPdfProcessor : IPdfProcessor /// PDF content as byte array /// PDF metadata (page count, file size, version, etc.) /// Thrown when PDF is invalid or null - public async Task ValidateAsync(byte[] pdfBytes) + public async Task ValidateAsync(byte[] pdfBytes) { // 1. Input Validation (Defensive Programming) if (pdfBytes == null) @@ -46,8 +47,8 @@ public class DevExpressPdfProcessor : IPdfProcessor // We scan PDF raw data for "/EmbeddedFiles" and parse the name tree to get count. var (hasAttachments, attachmentCount) = DetectEmbeddedFiles(pdfBytes); - // 4. Create and return PdfMetadata Value Object (fully qualified name!) - return new DocumentOperator.Domain.Models.ValueObjects.PdfMetadata( + // 4. Create and return PdfMetadata Value Object + return new Domain.Models.ValueObjects.PdfMetadata( pageCount: pageCount, fileSizeBytes: pdfBytes.Length, pdfVersion: pdfVersion, @@ -64,6 +65,126 @@ public class DevExpressPdfProcessor : IPdfProcessor } } + /// + /// Validates a PDF/A document and checks conformance level. + /// + /// PDF content as byte array + /// PDF/A metadata including conformance level and validation errors/warnings + /// Thrown when PDF is invalid or null + public async Task ValidatePdfAAsync(byte[] pdfBytes) + { + // 1. Input Validation + if (pdfBytes == null) + { + throw new PdfProcessingException("PDF bytes cannot be null"); + } + + if (pdfBytes.Length == 0) + { + throw new PdfProcessingException("PDF bytes cannot be empty"); + } + + try + { + // 2. Load PDF with DevExpress Document API + using var processor = new PdfDocumentProcessor(); + processor.LoadDocument(new MemoryStream(pdfBytes)); + + var document = processor.Document; + + // 3. Extract basic metadata + int pageCount = document.Pages.Count; + string pdfVersion = document.Version.ToString(); + + // 4. Check encryption (scan PDF raw data for /Encrypt keyword) + bool encrypted = DetectEncryption(pdfBytes); + + // 5. Check PDF/A conformance (scan PDF raw data for PDF/A identifier) + var (isPdfACompliant, pdfaVersion) = DetectPdfAConformance(pdfBytes); + + // 6. Collect errors and warnings + var errors = new List(); + var warnings = new List(); + + // If encrypted, PDF/A compliance is not possible + if (encrypted && isPdfACompliant) + { + errors.Add("PDF/A documents cannot be encrypted"); + isPdfACompliant = false; + } + + // Basic PDF/A validation checks + if (isPdfACompliant) + { + // Add generic warning for manual verification + warnings.Add("Manual verification recommended: All fonts must be embedded"); + warnings.Add("Manual verification recommended: No JavaScript or multimedia content"); + } + + // 7. Determine overall validity + bool isValid = errors.Count == 0; + + // 8. Create and return PdfAMetadata + return new PdfAMetadata( + isValid: isValid, + pdfVersion: pdfVersion, + pageCount: pageCount, + fileSizeBytes: pdfBytes.Length, + encrypted: encrypted, + pdfaVersion: pdfaVersion, + pdfaCompliant: isPdfACompliant, + errors: errors, + warnings: warnings + ); + } + catch (Exception ex) when (ex is not PdfProcessingException) + { + throw new PdfProcessingException( + $"Failed to validate PDF/A: {ex.Message}", + ex); + } + } + + /// + /// Detects if PDF is encrypted by scanning for /Encrypt keyword. + /// + private static bool DetectEncryption(byte[] pdfBytes) + { + string pdfText = System.Text.Encoding.ASCII.GetString(pdfBytes); + return pdfText.Contains("/Encrypt", StringComparison.Ordinal); + } + + /// + /// Detects PDF/A conformance level by scanning PDF metadata. + /// PDF/A documents contain an XMP metadata stream with pdfaid:conformance and pdfaid:part. + /// + private static (bool isPdfACompliant, string? pdfaVersion) DetectPdfAConformance(byte[] pdfBytes) + { + string pdfText = System.Text.Encoding.ASCII.GetString(pdfBytes); + + // Look for PDF/A identifier in XMP metadata + // Example: 1B + if (pdfText.Contains("pdfaid:part", StringComparison.Ordinal)) + { + // Try to extract part and conformance level + var partMatch = System.Text.RegularExpressions.Regex.Match(pdfText, @"pdfaid:part>(\d+)([ABU]) /// Detects embedded files in PDF by scanning raw PDF data for /EmbeddedFiles keyword /// and parsing the name tree to count attachments.