DevExpressPdfProcessor: - ValidateAsync, ValidatePdfAAsync, CheckAttachmentsAsync: Stream parameters - Defensive Position=0 validation (BadRequestException for seekable streams not at beginning) - Remove unsafe Position reset (non-seekable stream compatibility) - Remove PdfProcessingException wrapping (let DevExpress exceptions propagate naturally) DevExpressSwissQrCodeProcessor: - ExtractSwissQrCodeAsync: Stream parameter - Defensive Position=0 validation - Remove unsafe Position reset Memory optimization: MemoryStream.TryGetBuffer fast path for byte[] extraction
351 lines
14 KiB
C#
351 lines
14 KiB
C#
using DevExpress.Pdf;
|
|
using DocumentOperator.Application.Common.DTOs;
|
|
using DocumentOperator.Application.Common.Interfaces;
|
|
using DocumentOperator.Domain.Common.Exceptions;
|
|
|
|
namespace DocumentOperator.Infrastructure.Services.PdfProcessing;
|
|
|
|
/// <summary>
|
|
/// PDF processor implementation using DevExpress.Pdf library.
|
|
/// Handles PDF validation, metadata extraction, and attachment operations.
|
|
/// </summary>
|
|
public class DevExpressPdfProcessor : IPdfProcessor
|
|
{
|
|
#region PDF Validation
|
|
/// <summary>
|
|
/// Validates a PDF document and returns metadata.
|
|
/// </summary>
|
|
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
|
|
/// <returns>PDF metadata (page count, file size, version, etc.)</returns>
|
|
/// <exception cref="BadRequestException">Thrown when stream is empty or invalid</exception>
|
|
public async Task<Application.Common.DTOs.PdfMetadata> ValidateAsync(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. Read stream to byte array for raw data analysis
|
|
// (DevExpress needs byte[] for some operations like attachment detection)
|
|
byte[] pdfBytes;
|
|
if (pdfStream is MemoryStream ms && ms.TryGetBuffer(out var buffer))
|
|
{
|
|
// Fast path: reuse MemoryStream buffer
|
|
pdfBytes = buffer.Array!;
|
|
}
|
|
else
|
|
{
|
|
// Slow path: copy stream to byte array
|
|
using var memoryStream = new MemoryStream();
|
|
await pdfStream.CopyToAsync(memoryStream);
|
|
pdfBytes = memoryStream.ToArray();
|
|
}
|
|
|
|
// 3. Load PDF with DevExpress Document API
|
|
// Reset position for DevExpress (seekable streams only)
|
|
if (pdfStream.CanSeek)
|
|
{
|
|
pdfStream.Position = 0;
|
|
}
|
|
|
|
using var processor = new PdfDocumentProcessor();
|
|
processor.LoadDocument(pdfStream);
|
|
|
|
// 4. Extract metadata
|
|
var document = processor.Document;
|
|
|
|
int pageCount = document.Pages.Count;
|
|
string pdfVersion = document.Version.ToString(); // e.g., "1.4", "1.7"
|
|
|
|
// Attachments (embedded files)
|
|
// DevExpress PdfDocument API doesn't expose EmbeddedFiles directly in old API.
|
|
// We scan PDF raw data for "/EmbeddedFiles" and parse the name tree to get count.
|
|
var (hasAttachments, attachmentCount) = DetectEmbeddedFiles(pdfBytes);
|
|
|
|
// 5. Create and return PdfMetadata DTO
|
|
return new Application.Common.DTOs.PdfMetadata(
|
|
pageCount: pageCount,
|
|
fileSizeBytes: pdfBytes.Length,
|
|
pdfVersion: pdfVersion,
|
|
hasAttachments: hasAttachments,
|
|
attachmentCount: attachmentCount
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates a PDF/A document and checks conformance level.
|
|
/// </summary>
|
|
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
|
|
/// <returns>PDF/A metadata including conformance level and validation errors/warnings</returns>
|
|
/// <exception cref="BadRequestException">Thrown when stream is empty or invalid</exception>
|
|
public async Task<PdfAMetadata> ValidatePdfAAsync(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. Read stream to byte array for raw data analysis
|
|
byte[] pdfBytes;
|
|
if (pdfStream is MemoryStream ms && ms.TryGetBuffer(out var buffer))
|
|
{
|
|
pdfBytes = buffer.Array!;
|
|
}
|
|
else
|
|
{
|
|
using var memoryStream = new MemoryStream();
|
|
await pdfStream.CopyToAsync(memoryStream);
|
|
pdfBytes = memoryStream.ToArray();
|
|
}
|
|
|
|
// 3. Load PDF with DevExpress Document API
|
|
// Reset position for DevExpress (seekable streams only)
|
|
if (pdfStream.CanSeek)
|
|
{
|
|
pdfStream.Position = 0;
|
|
}
|
|
|
|
using var processor = new PdfDocumentProcessor();
|
|
processor.LoadDocument(pdfStream);
|
|
|
|
var document = processor.Document;
|
|
|
|
// 4. Extract basic metadata
|
|
int pageCount = document.Pages.Count;
|
|
string pdfVersion = document.Version.ToString();
|
|
|
|
// 5. Check encryption (scan PDF raw data for /Encrypt keyword)
|
|
bool encrypted = DetectEncryption(pdfBytes);
|
|
|
|
// 6. Check PDF/A conformance (scan PDF raw data for PDF/A identifier)
|
|
var (isPdfACompliant, pdfaVersion) = DetectPdfAConformance(pdfBytes);
|
|
|
|
// 7. 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");
|
|
}
|
|
|
|
// 8. Determine overall validity
|
|
bool isValid = errors.Count == 0;
|
|
|
|
// 9. Create and return PdfAMetadata DTO
|
|
return new PdfAMetadata(
|
|
isValid: isValid,
|
|
pdfVersion: pdfVersion,
|
|
pageCount: pageCount,
|
|
fileSizeBytes: pdfBytes.Length,
|
|
encrypted: encrypted,
|
|
pdfaVersion: pdfaVersion,
|
|
pdfaCompliant: isPdfACompliant,
|
|
errors: errors,
|
|
warnings: warnings
|
|
);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Attachment Operations
|
|
|
|
/// <summary>
|
|
/// Checks for embedded files (attachments) in a PDF document and returns detailed metadata.
|
|
/// Uses DevExpress PdfDocument.FileAttachments collection to retrieve attachment details.
|
|
/// </summary>
|
|
/// <param name="pdfStream">PDF content as stream (caller is responsible for disposal)</param>
|
|
/// <returns>Attachment information including count, file names, MIME types, and sizes</returns>
|
|
/// <exception cref="BadRequestException">Thrown when stream is empty</exception>
|
|
public async Task<AttachmentInfo> CheckAttachmentsAsync(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
|
|
// 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);
|
|
|
|
var document = processor.Document;
|
|
|
|
// 3. Extract attachment details using DevExpress FileAttachments collection
|
|
var fileAttachments = document.FileAttachments;
|
|
|
|
// 4. No attachments case (FileAttachments is IEnumerable<PdfFileAttachment>)
|
|
if (fileAttachments == null || !fileAttachments.Any())
|
|
{
|
|
return AttachmentInfo.Empty;
|
|
}
|
|
|
|
// 5. Map DevExpress PdfFileAttachment to our DTO AttachmentMetadata
|
|
var attachments = fileAttachments.Select(devExpressAttachment =>
|
|
new AttachmentMetadata(
|
|
fileName: devExpressAttachment.FileName ?? "unnamed",
|
|
mimeType: devExpressAttachment.MimeType ?? "application/octet-stream",
|
|
sizeBytes: devExpressAttachment.Size
|
|
)).ToList();
|
|
|
|
// 6. Return AttachmentInfo DTO
|
|
return new AttachmentInfo(
|
|
hasAttachments: true,
|
|
attachmentCount: attachments.Count,
|
|
attachments: attachments.AsReadOnly()
|
|
);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Private Helpers
|
|
|
|
/// <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.
|
|
/// This is a pragmatic approach as DevExpress PdfDocument API doesn't expose EmbeddedFiles directly.
|
|
/// </summary>
|
|
/// <param name="pdfBytes">PDF raw bytes</param>
|
|
/// <returns>Tuple: (hasAttachments, attachmentCount)</returns>
|
|
private static (bool hasAttachments, int attachmentCount) DetectEmbeddedFiles(byte[] pdfBytes)
|
|
{
|
|
// PDF embedded files are declared in the document catalog:
|
|
// /Names << /EmbeddedFiles << /Names [...] >> >>
|
|
// The /Names array contains pairs: [name1, filespec1, name2, filespec2, ...]
|
|
|
|
string pdfText = System.Text.Encoding.ASCII.GetString(pdfBytes);
|
|
|
|
// Search for /EmbeddedFiles in the context of /Names dictionary
|
|
// Must appear after a /Names keyword to be valid
|
|
int searchStart = 0;
|
|
|
|
while (true)
|
|
{
|
|
// Find next occurrence of /EmbeddedFiles
|
|
int embeddedFilesIndex = pdfText.IndexOf("/EmbeddedFiles", searchStart, StringComparison.Ordinal);
|
|
|
|
if (embeddedFilesIndex == -1)
|
|
return (false, 0); // Not found
|
|
|
|
// Check if there's a /Names keyword BEFORE this /EmbeddedFiles
|
|
// within a reasonable distance (e.g., within the same PDF object, max 5000 chars back)
|
|
int contextStart = Math.Max(0, embeddedFilesIndex - 5000);
|
|
string contextBefore = pdfText.Substring(contextStart, embeddedFilesIndex - contextStart);
|
|
|
|
// Look for /Names in the context before /EmbeddedFiles
|
|
int lastNamesIndex = contextBefore.LastIndexOf("/Names", StringComparison.Ordinal);
|
|
|
|
if (lastNamesIndex != -1)
|
|
{
|
|
// Found /Names before /EmbeddedFiles - this is likely a valid embedded files declaration
|
|
// Now try to parse the /Names array
|
|
int namesArrayStart = pdfText.IndexOf("/Names", embeddedFilesIndex, StringComparison.Ordinal);
|
|
if (namesArrayStart == -1)
|
|
return (true, 0); // Has EmbeddedFiles but can't count
|
|
|
|
int arrayStart = pdfText.IndexOf('[', namesArrayStart);
|
|
if (arrayStart == -1)
|
|
return (true, 0); // Has EmbeddedFiles but can't count
|
|
|
|
int arrayEnd = pdfText.IndexOf(']', arrayStart);
|
|
if (arrayEnd == -1)
|
|
return (true, 0); // Has EmbeddedFiles but can't count
|
|
|
|
// Extract array content and count entries
|
|
string arrayContent = pdfText.Substring(arrayStart + 1, arrayEnd - arrayStart - 1);
|
|
|
|
// Count object references in array
|
|
// The /Names array contains pairs: (filename) objectReference (filename) objectReference ...
|
|
// Each object reference (pattern: "123 0 R") points to one embedded file
|
|
// So the number of object references = number of attachments
|
|
int objectCount = System.Text.RegularExpressions.Regex.Matches(arrayContent, @"\d+ \d+ R").Count;
|
|
|
|
return (true, Math.Max(1, objectCount)); // At least 1 if EmbeddedFiles found
|
|
}
|
|
|
|
// This /EmbeddedFiles was not in the right context, search for next occurrence
|
|
searchStart = embeddedFilesIndex + 1;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|