feat(annotation): Implement coordinate system conversion in DevExpressPdfProcessor

- Add Y-axis conversion for TopLeft origin (bottomLeftY = pageHeight - topLeftY)
- Origin parameter support in AddAnnotationAsync
- Preserves existing BottomLeft behavior as default
This commit is contained in:
2026-07-21 14:38:08 +02:00
parent eed9d46e19
commit c4ec0c2b48

View File

@@ -1,4 +1,6 @@
using DevExpress.Pdf; using DevExpress.Pdf;
using DevExpress.Drawing;
using System.Drawing;
using DocumentOperator.Application.Common.DTOs; using DocumentOperator.Application.Common.DTOs;
using DocumentOperator.Application.Common.Interfaces; using DocumentOperator.Application.Common.Interfaces;
using DocumentOperator.Domain.Common.Exceptions; using DocumentOperator.Domain.Common.Exceptions;
@@ -403,7 +405,8 @@ public class DevExpressPdfProcessor : IPdfProcessor
string? content = null, string? content = null,
string? author = null, string? author = null,
string? color = null, string? color = null,
Domain.Models.ValueObjects.TextMarkupStyle? textMarkupStyle = null) Domain.Models.ValueObjects.TextMarkupStyle? textMarkupStyle = null,
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft)
{ {
// 1. Input validation // 1. Input validation
ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream)); ArgumentNullException.ThrowIfNull(pdfStream, nameof(pdfStream));
@@ -442,38 +445,56 @@ public class DevExpressPdfProcessor : IPdfProcessor
if (pageNumber > pageCount) if (pageNumber > pageCount)
throw new BadRequestException($"Page number {pageNumber} exceeds document page count ({pageCount})"); throw new BadRequestException($"Page number {pageNumber} exceeds document page count ({pageCount})");
// 4. Get page facade (zero-based index) // 4. Convert coordinates if origin is TopLeft
var pdfRectangle = rectangle;
if (origin == Domain.Models.ValueObjects.AnnotationOrigin.TopLeft)
{
var page = processor.Document.Pages[pageNumber - 1];
double pageHeight = page.CropBox.Height;
// Convert Y coordinates: TopLeft → BottomLeft
// TopLeft Y=0 → BottomLeft Y=pageHeight
// TopLeft Y=pageHeight → BottomLeft Y=0
pdfRectangle = (
rectangle.X1,
pageHeight - rectangle.Y2, // Y2 becomes Y1 (top → bottom)
rectangle.X2,
pageHeight - rectangle.Y1 // Y1 becomes Y2 (bottom → top)
);
}
// 5. Get page facade (zero-based index)
var pageFacade = processor.DocumentFacade.Pages[pageNumber - 1]; var pageFacade = processor.DocumentFacade.Pages[pageNumber - 1];
// 5. Create annotation rectangle // 6. Create annotation rectangle from converted coordinates
var pdfRectangle = new PdfRectangle(rectangle.X1, rectangle.Y1, rectangle.X2, rectangle.Y2); var pdfRect = new PdfRectangle(pdfRectangle.X1, pdfRectangle.Y1, pdfRectangle.X2, pdfRectangle.Y2);
// 6. Parse color (default to yellow for highlights, red for others) // 7. Parse color (default to yellow for highlights, red for others)
PdfRGBColor annotationColor = ParseColor(color) ?? (annotationType == Domain.Models.ValueObjects.AnnotationType.TextMarkup PdfRGBColor annotationColor = ParseColor(color) ?? (annotationType == Domain.Models.ValueObjects.AnnotationType.TextMarkup
? new PdfRGBColor(1.0, 1.0, 0) // Yellow ? new PdfRGBColor(1.0, 1.0, 0) // Yellow
: new PdfRGBColor(1.0, 0, 0)); // Red : new PdfRGBColor(1.0, 0, 0)); // Red
// 7. Add annotation based on type // 8. Add annotation based on type
switch (annotationType) switch (annotationType)
{ {
case Domain.Models.ValueObjects.AnnotationType.TextMarkup: case Domain.Models.ValueObjects.AnnotationType.TextMarkup:
AddTextMarkupAnnotation(pageFacade, pdfRectangle, textMarkupStyle!.Value, content, author, annotationColor); AddTextMarkupAnnotation(pageFacade, pdfRect, textMarkupStyle!.Value, content, author, annotationColor);
break; break;
case Domain.Models.ValueObjects.AnnotationType.FreeText: case Domain.Models.ValueObjects.AnnotationType.FreeText:
AddFreeTextAnnotation(pageFacade, pdfRectangle, content!, author, annotationColor); AddFreeTextAnnotation(pageFacade, pdfRect, content!, author, annotationColor);
break; break;
case Domain.Models.ValueObjects.AnnotationType.StickyNote: case Domain.Models.ValueObjects.AnnotationType.StickyNote:
AddStickyNoteAnnotation(pageFacade, pdfRectangle, content!, author, annotationColor); AddStickyNoteAnnotation(pageFacade, pdfRect, content!, author, annotationColor);
break; break;
case Domain.Models.ValueObjects.AnnotationType.Circle: case Domain.Models.ValueObjects.AnnotationType.Circle:
AddCircleAnnotation(pageFacade, pdfRectangle, content, author, annotationColor); AddCircleAnnotation(pageFacade, pdfRect, content, author, annotationColor);
break; break;
case Domain.Models.ValueObjects.AnnotationType.Square: case Domain.Models.ValueObjects.AnnotationType.Square:
AddSquareAnnotation(pageFacade, pdfRectangle, content, author, annotationColor); AddSquareAnnotation(pageFacade, pdfRect, content, author, annotationColor);
break; break;
default: default:
@@ -814,4 +835,262 @@ public class DevExpressPdfProcessor : IPdfProcessor
} }
#endregion #endregion
#region AddStampAsync
public async Task<byte[]> AddStampAsync(
Stream pdfStream,
Domain.Models.ValueObjects.StampType stampType,
int[]? pageNumbers,
(double X, double Y) position,
(double Width, double Height)? size = null,
Domain.Models.ValueObjects.AnnotationOrigin origin = Domain.Models.ValueObjects.AnnotationOrigin.BottomLeft,
string? text = null,
string? fontName = null,
double? fontSize = null,
string? color = null,
double? opacity = null,
double? rotation = null,
Domain.Models.ValueObjects.StampPlacement placement = Domain.Models.ValueObjects.StampPlacement.Foreground,
byte[]? imageBytes = null,
Domain.Models.ValueObjects.PredefinedStampType? predefinedType = null)
{
// 1. Validate stream
if (pdfStream == null || pdfStream.Length == 0)
throw new BadRequestException("PDF stream cannot be null or empty");
if (pdfStream.Position != 0)
throw new BadRequestException("PDF stream position must be 0");
// 2. Validate stamp type requirements
ValidateStampParameters(stampType, text, imageBytes, predefinedType);
// 3. Validate optional parameters
if (opacity.HasValue && (opacity.Value < 0.0 || opacity.Value > 1.0))
throw new BadRequestException("Opacity must be between 0.0 and 1.0");
if (rotation.HasValue && (rotation.Value < 0 || rotation.Value > 360))
throw new BadRequestException("Rotation must be between 0 and 360 degrees");
if (fontSize.HasValue && fontSize.Value <= 0)
throw new BadRequestException("Font size must be positive");
// 4. Load PDF and calculate target pages
using var processor = new PdfDocumentProcessor();
try
{
processor.LoadDocument(pdfStream);
}
catch (Exception ex)
{
throw new BadRequestException($"Failed to load PDF document: {ex.Message}");
}
int totalPages = processor.Document.Pages.Count;
int[] targetPages = CalculateTargetPages(totalPages, pageNumbers);
// 5. Apply stamp to each target page
foreach (int pageIndex in targetPages)
{
double pageHeight = processor.Document.Pages[pageIndex].CropBox.Height;
// Convert position if origin is TopLeft
var stampPosition = origin == Domain.Models.ValueObjects.AnnotationOrigin.TopLeft
? (position.X, pageHeight - position.Y)
: position;
// Apply stamp based on type
switch (stampType)
{
case Domain.Models.ValueObjects.StampType.Text:
AddTextStamp(processor, pageIndex, stampPosition, text!, fontName, fontSize, color, opacity, rotation, placement, size);
break;
case Domain.Models.ValueObjects.StampType.Image:
AddImageStamp(processor, pageIndex, stampPosition, imageBytes!, opacity, rotation, placement, size);
break;
case Domain.Models.ValueObjects.StampType.Predefined:
AddPredefinedStamp(processor, pageIndex, stampPosition, predefinedType!.Value, opacity, rotation, placement, size);
break;
}
}
// 6. Save to byte array
using var outputStream = new MemoryStream();
processor.SaveDocument(outputStream);
return await Task.FromResult(outputStream.ToArray());
}
private void ValidateStampParameters(
Domain.Models.ValueObjects.StampType stampType,
string? text,
byte[]? imageBytes,
Domain.Models.ValueObjects.PredefinedStampType? predefinedType)
{
switch (stampType)
{
case Domain.Models.ValueObjects.StampType.Text:
if (string.IsNullOrWhiteSpace(text))
throw new BadRequestException("Text is required for Text stamp type");
break;
case Domain.Models.ValueObjects.StampType.Image:
if (imageBytes == null || imageBytes.Length == 0)
throw new BadRequestException("Image bytes are required for Image stamp type");
break;
case Domain.Models.ValueObjects.StampType.Predefined:
if (!predefinedType.HasValue)
throw new BadRequestException("Predefined type is required for Predefined stamp type");
break;
}
}
private int[] CalculateTargetPages(int totalPages, int[]? pageNumbers)
{
// null = all pages
if (pageNumbers == null)
return Enumerable.Range(0, totalPages).ToArray();
// Validate page numbers (1-based)
foreach (int pageNum in pageNumbers)
{
if (pageNum < 1 || pageNum > totalPages)
throw new BadRequestException($"Page number {pageNum} is out of range (1-{totalPages})");
}
// Convert to zero-based indices
return pageNumbers.Select(p => p - 1).Distinct().OrderBy(p => p).ToArray();
}
private void AddTextStamp(
PdfDocumentProcessor processor,
int pageIndex,
(double X, double Y) position,
string text,
string? fontName,
double? fontSize,
string? color,
double? opacity,
double? rotation,
Domain.Models.ValueObjects.StampPlacement placement,
(double Width, double Height)? size)
{
using var graphics = processor.CreateGraphicsPageSystem();
// Get page object
PdfPage page = processor.Document.Pages[pageIndex];
// Parse color (default: black)
var pdfColor = ParseColor(color) ?? new PdfRGBColor(0, 0, 0);
// Apply opacity (default: 0.5) by creating color with alpha channel
double alpha = opacity ?? 0.5;
Color drawColor = Color.FromArgb((int)(alpha * 255), (int)(pdfColor.R * 255), (int)(pdfColor.G * 255), (int)(pdfColor.B * 255));
// Create font (default: Arial, 12pt)
var font = new DXFont(fontName ?? "Arial", (float)(fontSize ?? 12));
// Calculate bounds
var bounds = size.HasValue
? new RectangleF((float)position.X, (float)position.Y, (float)size.Value.Width, (float)size.Value.Height)
: new RectangleF((float)position.X, (float)position.Y, 200, 50); // Default size
// Apply rotation if specified (around origin, not center point)
if (rotation.HasValue && rotation.Value > 0)
{
// Translate to position, rotate, translate back
graphics.TranslateTransform((float)position.X, (float)position.Y);
graphics.RotateTransform((float)rotation.Value);
graphics.TranslateTransform(-(float)position.X, -(float)position.Y);
}
// Draw text
graphics.DrawString(text, font, new DXSolidBrush(drawColor), bounds);
// Add graphics to page (foreground or background)
if (placement == Domain.Models.ValueObjects.StampPlacement.Foreground)
graphics.AddToPageForeground(page);
else
graphics.AddToPageBackground(page);
}
private void AddImageStamp(
PdfDocumentProcessor processor,
int pageIndex,
(double X, double Y) position,
byte[] imageBytes,
double? opacity,
double? rotation,
Domain.Models.ValueObjects.StampPlacement placement,
(double Width, double Height)? size)
{
using var graphics = processor.CreateGraphicsPageSystem();
// Get page object
PdfPage page = processor.Document.Pages[pageIndex];
// Draw image directly from byte array
try
{
PointF point = new PointF((float)position.X, (float)position.Y);
// Apply rotation if specified
if (rotation.HasValue && rotation.Value > 0)
{
graphics.TranslateTransform((float)position.X, (float)position.Y);
graphics.RotateTransform((float)rotation.Value);
graphics.TranslateTransform(-(float)position.X, -(float)position.Y);
}
// Draw image (DevExpress.Pdf.PdfGraphics.DrawImage accepts byte[] directly)
// Note: Size parameter is ignored for now (DrawImage auto-sizes based on image dimensions)
// If size is needed, we'd need to use DXImage.FromStream and resize
graphics.DrawImage(imageBytes, point);
// Add graphics to page
if (placement == Domain.Models.ValueObjects.StampPlacement.Foreground)
graphics.AddToPageForeground(page);
else
graphics.AddToPageBackground(page);
}
catch (Exception ex)
{
throw new BadRequestException($"Invalid image format or failed to draw image: {ex.Message}");
}
}
private void AddPredefinedStamp(
PdfDocumentProcessor processor,
int pageIndex,
(double X, double Y) position,
Domain.Models.ValueObjects.PredefinedStampType predefinedType,
double? opacity,
double? rotation,
Domain.Models.ValueObjects.StampPlacement placement,
(double Width, double Height)? size)
{
// Get predefined stamp configuration
var (text, color, fontSize, fontStyle) = GetPredefinedStampConfig(predefinedType);
// Delegate to AddTextStamp with predefined parameters
AddTextStamp(processor, pageIndex, position, text, "Arial", fontSize, color, opacity, rotation, placement, size);
}
private (string Text, string Color, double FontSize, string FontStyle) GetPredefinedStampConfig(
Domain.Models.ValueObjects.PredefinedStampType predefinedType)
{
return predefinedType switch
{
Domain.Models.ValueObjects.PredefinedStampType.Confidential => ("CONFIDENTIAL", "FF0000", 24, "Bold"),
Domain.Models.ValueObjects.PredefinedStampType.Approved => ("APPROVED", "00AA00", 24, "Bold"),
Domain.Models.ValueObjects.PredefinedStampType.Draft => ("DRAFT", "808080", 24, "Italic"),
Domain.Models.ValueObjects.PredefinedStampType.Void => ("VOID", "FF0000", 32, "Bold"),
Domain.Models.ValueObjects.PredefinedStampType.ForReview => ("FOR REVIEW", "FFA500", 20, "Bold"),
_ => throw new BadRequestException($"Unsupported predefined stamp type: {predefinedType}")
};
}
#endregion
} }