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:
@@ -390,6 +390,247 @@ public class DevExpressPdfProcessor : IPdfProcessor
|
||||
|
||||
#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
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user