diff --git a/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs b/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs index 7bfb97e..59df11c 100644 --- a/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs +++ b/DocumentOperator.Infrastructure/Services/PdfProcessing/DevExpressPdfProcessor.cs @@ -390,6 +390,247 @@ public class DevExpressPdfProcessor : IPdfProcessor #endregion + #region PDF Annotation + + /// + /// Adds an annotation to a PDF document at the specified page and rectangle. + /// + public async Task AddAnnotationAsync( + Stream pdfStream, + Domain.Models.ValueObjects.AnnotationType annotationType, + int pageNumber, + (double X1, double Y1, double X2, double Y2) rectangle, + string? content = null, + string? author = null, + string? color = null, + Domain.Models.ValueObjects.TextMarkupStyle? textMarkupStyle = null) + { + // 1. Input validation + ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream)); + + if (pdfStream.Length == 0) + throw new BadRequestException("PDF stream cannot be empty"); + + if (pdfStream.Position != 0) + throw new BadRequestException($"PDF stream must be at position 0 (current position: {pdfStream.Position})"); + + if (pageNumber < 1) + throw new BadRequestException($"Page number must be >= 1 (provided: {pageNumber})"); + + // Validate content requirement + if (annotationType is Domain.Models.ValueObjects.AnnotationType.FreeText + or Domain.Models.ValueObjects.AnnotationType.StickyNote) + { + if (string.IsNullOrWhiteSpace(content)) + throw new BadRequestException($"{annotationType} annotation requires content"); + } + + // Validate TextMarkup style requirement + if (annotationType == Domain.Models.ValueObjects.AnnotationType.TextMarkup && textMarkupStyle == null) + throw new BadRequestException("TextMarkup annotation requires textMarkupStyle parameter"); + + byte[] annotatedPdfBytes; + + try + { + // 2. Load PDF + using var processor = new PdfDocumentProcessor(); + processor.LoadDocument(pdfStream); + + // 3. Validate page number + int pageCount = processor.Document.Pages.Count; + if (pageNumber > pageCount) + throw new BadRequestException($"Page number {pageNumber} exceeds document page count ({pageCount})"); + + // 4. Get page facade (zero-based index) + var pageFacade = processor.DocumentFacade.Pages[pageNumber - 1]; + + // 5. Create annotation rectangle + var pdfRectangle = new PdfRectangle(rectangle.X1, rectangle.Y1, rectangle.X2, rectangle.Y2); + + // 6. Parse color (default to yellow for highlights, red for others) + PdfRGBColor annotationColor = ParseColor(color) ?? (annotationType == Domain.Models.ValueObjects.AnnotationType.TextMarkup + ? new PdfRGBColor(1.0, 1.0, 0) // Yellow + : new PdfRGBColor(1.0, 0, 0)); // Red + + // 7. Add annotation based on type + switch (annotationType) + { + case Domain.Models.ValueObjects.AnnotationType.TextMarkup: + AddTextMarkupAnnotation(pageFacade, pdfRectangle, textMarkupStyle!.Value, content, author, annotationColor); + break; + + case Domain.Models.ValueObjects.AnnotationType.FreeText: + AddFreeTextAnnotation(pageFacade, pdfRectangle, content!, author, annotationColor); + break; + + case Domain.Models.ValueObjects.AnnotationType.StickyNote: + AddStickyNoteAnnotation(pageFacade, pdfRectangle, content!, author, annotationColor); + break; + + case Domain.Models.ValueObjects.AnnotationType.Circle: + AddCircleAnnotation(pageFacade, pdfRectangle, content, author, annotationColor); + break; + + case Domain.Models.ValueObjects.AnnotationType.Square: + AddSquareAnnotation(pageFacade, pdfRectangle, content, author, annotationColor); + break; + + default: + throw new BadRequestException($"Unsupported annotation type: {annotationType}"); + } + + // 8. Save annotated PDF + using var outputStream = new MemoryStream(); + processor.SaveDocument(outputStream); + annotatedPdfBytes = outputStream.ToArray(); + } + catch (BadRequestException) + { + throw; // Re-throw our own exceptions + } + catch (Exception ex) + { + throw new BadRequestException($"Failed to add annotation: {ex.Message}"); + } + + return await Task.FromResult(annotatedPdfBytes); + } + + private void AddTextMarkupAnnotation( + PdfPageFacade pageFacade, + PdfRectangle rectangle, + Domain.Models.ValueObjects.TextMarkupStyle style, + string? content, + string? author, + PdfRGBColor color) + { + // Map our enum to DevExpress enum + var devExpressStyle = style switch + { + Domain.Models.ValueObjects.TextMarkupStyle.Highlight => PdfTextMarkupAnnotationType.Highlight, + Domain.Models.ValueObjects.TextMarkupStyle.Underline => PdfTextMarkupAnnotationType.Underline, + Domain.Models.ValueObjects.TextMarkupStyle.Strikeout => PdfTextMarkupAnnotationType.StrikeOut, + _ => throw new BadRequestException($"Unsupported text markup style: {style}") + }; + + var annotation = pageFacade.AddTextMarkupAnnotation(rectangle, devExpressStyle); + + if (annotation != null) + { + annotation.Color = color; + if (!string.IsNullOrWhiteSpace(author)) + annotation.Author = author; + if (!string.IsNullOrWhiteSpace(content)) + annotation.Contents = content; + } + } + + private void AddFreeTextAnnotation( + PdfPageFacade pageFacade, + PdfRectangle rectangle, + string content, + string? author, + PdfRGBColor color) + { + var annotation = pageFacade.AddFreeTextAnnotation(rectangle, content); + + if (annotation != null) + { + annotation.Color = color; + if (!string.IsNullOrWhiteSpace(author)) + annotation.Author = author; + } + } + + private void AddStickyNoteAnnotation( + PdfPageFacade pageFacade, + PdfRectangle rectangle, + string content, + string? author, + PdfRGBColor color) + { + // Sticky note uses a point (top-left corner of rectangle) + var point = new PdfPoint(rectangle.Left, rectangle.Top); + var annotation = pageFacade.AddTextAnnotation(point); + + if (annotation != null) + { + annotation.Color = color; + annotation.Contents = content; + if (!string.IsNullOrWhiteSpace(author)) + annotation.Author = author; + } + } + + private void AddCircleAnnotation( + PdfPageFacade pageFacade, + PdfRectangle rectangle, + string? content, + string? author, + PdfRGBColor color) + { + var annotation = pageFacade.AddCircleAnnotation(rectangle); + + if (annotation != null) + { + annotation.Color = color; + if (!string.IsNullOrWhiteSpace(author)) + annotation.Author = author; + if (!string.IsNullOrWhiteSpace(content)) + annotation.Contents = content; + } + } + + private void AddSquareAnnotation( + PdfPageFacade pageFacade, + PdfRectangle rectangle, + string? content, + string? author, + PdfRGBColor color) + { + var annotation = pageFacade.AddSquareAnnotation(rectangle); + + if (annotation != null) + { + annotation.Color = color; + if (!string.IsNullOrWhiteSpace(author)) + annotation.Author = author; + if (!string.IsNullOrWhiteSpace(content)) + annotation.Contents = content; + } + } + + /// + /// Parses hex color string (e.g., "FF0000" for red) to PdfRGBColor + /// + private PdfRGBColor? ParseColor(string? hexColor) + { + if (string.IsNullOrWhiteSpace(hexColor)) + return null; + + try + { + // Remove '#' if present + hexColor = hexColor.TrimStart('#'); + + if (hexColor.Length != 6) + throw new BadRequestException($"Color must be 6-digit hex (e.g., 'FF0000'), got: '{hexColor}'"); + + int r = Convert.ToInt32(hexColor.Substring(0, 2), 16); + int g = Convert.ToInt32(hexColor.Substring(2, 2), 16); + int b = Convert.ToInt32(hexColor.Substring(4, 2), 16); + + return new PdfRGBColor(r / 255.0, g / 255.0, b / 255.0); + } + catch (Exception ex) + { + throw new BadRequestException($"Invalid color format: '{hexColor}'. Expected 6-digit hex (e.g., 'FF0000'). Error: {ex.Message}"); + } + } + + #endregion + #region Private Helpers ///