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.