Refactored `DevExpressPdfProcessor` to improve attachment detection: - Changed `DetectEmbeddedFiles` return type to a tuple for better handling of attachment presence and count. - Enhanced logic to parse `/Names` arrays and count object references for accurate attachment detection. - Implemented robust search for `/EmbeddedFiles` to handle multiple occurrences and ensure proper context validation. Updated PHASENPLAN.md and ROADMAP.md to reflect these changes, including the addition of fixes for attachment detection and counting logic. Added new tests in `DevExpressPdfProcessorTests`: - Verified detection of multiple attachments and accurate counts. - Ensured no crashes when processing PDFs with `/EmbeddedFiles`. Included a new test resource (`pdfWithMoreThanOneAttachment.pdf`) for validating multiple attachment scenarios.
135 lines
5.9 KiB
C#
135 lines
5.9 KiB
C#
using DevExpress.Pdf;
|
|
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 and metadata extraction.
|
|
/// </summary>
|
|
public class DevExpressPdfProcessor : IPdfProcessor
|
|
{
|
|
/// <summary>
|
|
/// Validates a PDF document and returns metadata.
|
|
/// </summary>
|
|
/// <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)
|
|
{
|
|
// 1. Input Validation (Defensive Programming)
|
|
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 (PdfDocumentProcessor)
|
|
using var processor = new PdfDocumentProcessor();
|
|
processor.LoadDocument(new MemoryStream(pdfBytes));
|
|
|
|
// 3. Extract metadata
|
|
var document = processor.Document;
|
|
|
|
int pageCount = document.Pages.Count;
|
|
string pdfVersion = document.Version.ToString(); // z.B. "1.4", "1.7"
|
|
|
|
// Attachments (embedded files)
|
|
// DevExpress PdfDocument API doesn't expose EmbeddedFiles directly.
|
|
// 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(
|
|
pageCount: pageCount,
|
|
fileSizeBytes: pdfBytes.Length,
|
|
pdfVersion: pdfVersion,
|
|
hasAttachments: hasAttachments,
|
|
attachmentCount: attachmentCount
|
|
);
|
|
}
|
|
catch (Exception ex) when (ex is not PdfProcessingException)
|
|
{
|
|
// Wrap DevExpress exceptions in our domain exception
|
|
throw new PdfProcessingException(
|
|
$"Failed to validate PDF: {ex.Message}",
|
|
ex);
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
}
|