feat(infrastructure): Implement DevExpressPdfProcessor.AddAnnotationAsync

- Implement AddAnnotationAsync using DevExpress PdfDocumentProcessor
- Add 5 private helper methods (one per annotation type)
- Add ParseColor helper (hex string to PdfRGBColor)
- Validation: content required for FreeText/StickyNote, style for TextMarkup
- Default colors: Yellow for TextMarkup, Red for others
- Handle DevExpress API quirks (TextMarkupStyle.StrikeOut capitalization)
This commit is contained in:
2026-07-21 12:28:05 +02:00
parent 25fbea205f
commit 41f97ce533

View File

@@ -390,6 +390,247 @@ public class DevExpressPdfProcessor : IPdfProcessor
#endregion #endregion
#region PDF Annotation
/// <summary>
/// Adds an annotation to a PDF document at the specified page and rectangle.
/// </summary>
public async Task<byte[]> 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;
}
}
/// <summary>
/// Parses hex color string (e.g., "FF0000" for red) to PdfRGBColor
/// </summary>
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 #region Private Helpers
/// <summary> /// <summary>