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
This commit is contained in:
2026-07-09 12:59:24 +02:00
parent 1ff7cbea11
commit cd50d45bd5
8 changed files with 365 additions and 3 deletions

View File

@@ -1,5 +1,6 @@
using DocumentOperator.Application.Common.DTOs; using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.ValidatePdf.Queries; using DocumentOperator.Application.ValidatePdf.Queries;
using DocumentOperator.Application.ValidatePdfA.Queries;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -76,4 +77,68 @@ public class PdfValidationController(IMediator Mediator) : ControllerBase
return Ok(result); return Ok(result);
} }
/// <summary>
/// Validates a PDF/A document and checks conformance level (multipart/form-data)
/// </summary>
/// <param name="file">PDF file to validate</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>
/// <response code="400">Invalid PDF or file format</response>
/// <response code="500">Internal server error during validation</response>
[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<IActionResult> 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);
}
/// <summary>
/// Validates a PDF/A document and checks conformance level (Base64 JSON)
/// </summary>
/// <param name="query">PDF as Base64 string</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>
/// <response code="400">Invalid PDF or Base64 format</response>
/// <response code="500">Internal server error during validation</response>
[HttpPost("validate-pdfa")]
[Consumes("application/json")]
[ProducesResponseType(typeof(PdfAValidationResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> ValidatePdfAFromBase64(
[FromBody] ValidatePdfAQuery query,
CancellationToken cancellationToken)
{
// Direct pass-through to MediatR
var result = await Mediator.Send(query, cancellationToken);
return Ok(result);
}
} }

View File

@@ -0,0 +1,52 @@
namespace DocumentOperator.Application.Common.DTOs;
/// <summary>
/// PDF/A validation result including conformance level and validation errors/warnings
/// </summary>
public record PdfAValidationResult
{
/// <summary>
/// Whether the PDF is valid (no errors)
/// </summary>
public bool IsValid { get; init; }
/// <summary>
/// PDF version (e.g., "1.4", "1.7")
/// </summary>
public string PdfVersion { get; init; } = string.Empty;
/// <summary>
/// Number of pages in the PDF
/// </summary>
public int PageCount { get; init; }
/// <summary>
/// File size in bytes
/// </summary>
public long FileSize { get; init; }
/// <summary>
/// Whether the PDF is encrypted
/// </summary>
public bool Encrypted { get; init; }
/// <summary>
/// PDF/A version (e.g., "PDF/A-1b", "PDF/A-2a", "PDF/A-3u") or null if not PDF/A compliant
/// </summary>
public string? PdfAVersion { get; init; }
/// <summary>
/// Whether the PDF conforms to PDF/A standard
/// </summary>
public bool PdfACompliant { get; init; }
/// <summary>
/// Validation errors (e.g., "PDF/A documents cannot be encrypted")
/// </summary>
public IReadOnlyList<string> Errors { get; init; } = [];
/// <summary>
/// Validation warnings (e.g., "Manual verification recommended: All fonts must be embedded")
/// </summary>
public IReadOnlyList<string> Warnings { get; init; } = [];
}

View File

@@ -13,4 +13,14 @@ public interface IPdfProcessor
/// Thrown when PDF is corrupted or cannot be processed /// Thrown when PDF is corrupted or cannot be processed
/// </exception> /// </exception>
Task<PdfMetadata> ValidateAsync(byte[] pdfBytes); Task<PdfMetadata> ValidateAsync(byte[] pdfBytes);
/// <summary>
/// Validates a PDF/A document and checks conformance level.
/// </summary>
/// <param name="pdfBytes">PDF content as byte array</param>
/// <returns>PDF/A metadata including conformance level and validation errors/warnings</returns>
/// <exception cref="Domain.Common.Exceptions.PdfProcessingException">
/// Thrown when PDF is corrupted or cannot be processed
/// </exception>
Task<PdfAMetadata> ValidatePdfAAsync(byte[] pdfBytes);
} }

View File

@@ -15,6 +15,10 @@ public class MappingProfile : Profile
// PdfMetadata -> PdfValidationResult // PdfMetadata -> PdfValidationResult
CreateMap<PdfMetadata, PdfValidationResult>(); CreateMap<PdfMetadata, PdfValidationResult>();
// PdfAMetadata -> PdfAValidationResult
CreateMap<PdfAMetadata, PdfAValidationResult>()
.ForMember(dest => dest.FileSize, opt => opt.MapFrom(src => src.FileSizeBytes));
// SwissQrCodeData -> SwissQrCodeDataDto // SwissQrCodeData -> SwissQrCodeDataDto
CreateMap<SwissQrCodeData, SwissQrCodeDataDto>(); CreateMap<SwissQrCodeData, SwissQrCodeDataDto>();

View File

@@ -0,0 +1,45 @@
using AutoMapper;
using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces;
using MediatR;
namespace DocumentOperator.Application.ValidatePdfA.Queries;
/// <summary>
/// Query for PDF/A validation (supports both byte array and Base64 input)
/// </summary>
public record ValidatePdfAQuery : IRequest<PdfAValidationResult>
{
/// <summary>
/// PDF as byte array (direct upload)
/// </summary>
public byte[]? PdfBytes { get; init; }
/// <summary>
/// PDF as Base64 string (API clients)
/// </summary>
public string? Base64Pdf { get; init; }
}
/// <summary>
/// Handler for ValidatePdfAQuery
/// Orchestrates PDF/A validation using IPdfProcessor and AutoMapper
/// </summary>
public class ValidatePdfAQueryHandler(IPdfProcessor PdfProcessor, IMapper Mapper)
: IRequestHandler<ValidatePdfAQuery, PdfAValidationResult>
{
/// <summary>
/// Validates PDF/A and returns metadata with conformance level
/// </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!);
// Call DevExpress service (can throw PdfProcessingException)
var metadata = await PdfProcessor.ValidatePdfAAsync(pdfBytes);
// Map domain entity to DTO using AutoMapper
return Mapper.Map<PdfAValidationResult>(metadata);
}
}

View File

@@ -0,0 +1,18 @@
using FluentValidation;
namespace DocumentOperator.Application.ValidatePdfA.Validators;
/// <summary>
/// Validator for ValidatePdfAQuery
/// Ensures exactly ONE input format is provided (PdfBytes XOR Base64Pdf)
/// </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");
}
}

View File

@@ -0,0 +1,47 @@
namespace DocumentOperator.Domain.Models.ValueObjects;
/// <summary>
/// PDF/A validation metadata including conformance level and validation errors/warnings
/// </summary>
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<string> Errors { get; }
public IReadOnlyList<string> 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<string> errors,
IReadOnlyList<string> 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}";
}
}

View File

@@ -1,6 +1,7 @@
using DevExpress.Pdf; using DevExpress.Pdf;
using DocumentOperator.Application.Common.Interfaces; using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Common.Exceptions; using DocumentOperator.Domain.Common.Exceptions;
using DocumentOperator.Domain.Models.ValueObjects;
namespace DocumentOperator.Infrastructure.Services.PdfProcessing; namespace DocumentOperator.Infrastructure.Services.PdfProcessing;
@@ -16,7 +17,7 @@ public class DevExpressPdfProcessor : IPdfProcessor
/// <param name="pdfBytes">PDF content as byte array</param> /// <param name="pdfBytes">PDF content as byte array</param>
/// <returns>PDF metadata (page count, file size, version, etc.)</returns> /// <returns>PDF metadata (page count, file size, version, etc.)</returns>
/// <exception cref="PdfProcessingException">Thrown when PDF is invalid or null</exception> /// <exception cref="PdfProcessingException">Thrown when PDF is invalid or null</exception>
public async Task<DocumentOperator.Domain.Models.ValueObjects.PdfMetadata> ValidateAsync(byte[] pdfBytes) public async Task<Domain.Models.ValueObjects.PdfMetadata> ValidateAsync(byte[] pdfBytes)
{ {
// 1. Input Validation (Defensive Programming) // 1. Input Validation (Defensive Programming)
if (pdfBytes == null) 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. // We scan PDF raw data for "/EmbeddedFiles" and parse the name tree to get count.
var (hasAttachments, attachmentCount) = DetectEmbeddedFiles(pdfBytes); var (hasAttachments, attachmentCount) = DetectEmbeddedFiles(pdfBytes);
// 4. Create and return PdfMetadata Value Object (fully qualified name!) // 4. Create and return PdfMetadata Value Object
return new DocumentOperator.Domain.Models.ValueObjects.PdfMetadata( return new Domain.Models.ValueObjects.PdfMetadata(
pageCount: pageCount, pageCount: pageCount,
fileSizeBytes: pdfBytes.Length, fileSizeBytes: pdfBytes.Length,
pdfVersion: pdfVersion, pdfVersion: pdfVersion,
@@ -64,6 +65,126 @@ public class DevExpressPdfProcessor : IPdfProcessor
} }
} }
/// <summary>
/// Validates a PDF/A document and checks conformance level.
/// </summary>
/// <param name="pdfBytes">PDF content as byte array</param>
/// <returns>PDF/A metadata including conformance level and validation errors/warnings</returns>
/// <exception cref="PdfProcessingException">Thrown when PDF is invalid or null</exception>
public async Task<PdfAMetadata> 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<string>();
var warnings = new List<string>();
// 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);
}
}
/// <summary>
/// Detects if PDF is encrypted by scanning for /Encrypt keyword.
/// </summary>
private static bool DetectEncryption(byte[] pdfBytes)
{
string pdfText = System.Text.Encoding.ASCII.GetString(pdfBytes);
return pdfText.Contains("/Encrypt", StringComparison.Ordinal);
}
/// <summary>
/// Detects PDF/A conformance level by scanning PDF metadata.
/// PDF/A documents contain an XMP metadata stream with pdfaid:conformance and pdfaid:part.
/// </summary>
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: <pdfaid:part>1</pdfaid:part><pdfaid:conformance>B</pdfaid:conformance>
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+)</pdfaid:part");
var conformanceMatch = System.Text.RegularExpressions.Regex.Match(pdfText, @"pdfaid:conformance>([ABU])</pdfaid:conformance");
if (partMatch.Success && conformanceMatch.Success)
{
string part = partMatch.Groups[1].Value; // "1", "2", "3"
string conformance = conformanceMatch.Groups[1].Value; // "A", "B", "U"
string pdfaVersion = $"PDF/A-{part}{conformance.ToLower()}";
return (true, pdfaVersion);
}
// Found pdfaid:part but couldn't parse details
return (true, "PDF/A (unknown level)");
}
return (false, null);
}
/// <summary> /// <summary>
/// Detects embedded files in PDF by scanning raw PDF data for /EmbeddedFiles keyword /// Detects embedded files in PDF by scanning raw PDF data for /EmbeddedFiles keyword
/// and parsing the name tree to count attachments. /// and parsing the name tree to count attachments.