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:
@@ -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
|
||||
/// <param name="pdfBytes">PDF content as byte array</param>
|
||||
/// <returns>PDF metadata (page count, file size, version, etc.)</returns>
|
||||
/// <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)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Detects embedded files in PDF by scanning raw PDF data for /EmbeddedFiles keyword
|
||||
/// and parsing the name tree to count attachments.
|
||||
|
||||
Reference in New Issue
Block a user