using DocumentOperator.Application.AddAnnotation;
using DocumentOperator.Application.AddStamp;
using DocumentOperator.Application.MergePdfs;
using DocumentOperator.Domain.Common.Exceptions;
using DocumentOperator.Domain.Models.ValueObjects;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace DocumentOperator.API.Controllers;
///
/// Controller for PDF operations (merge, stamp, annotate).
///
[ApiController]
[Route("api/pdf/operations")]
public class PdfOperationsController(IMediator mediator) : ControllerBase
{
///
/// Merges multiple PDF files into a single PDF.
/// Supports multipart/form-data file upload.
///
/// PDF files to merge (minimum 2 required)
/// Cancellation token
/// Merged PDF file
/// PDFs merged successfully - returns merged PDF
/// Invalid input (fewer than 2 files, corrupted PDF)
/// Internal server error during PDF processing
[HttpPost("merge", Name = "MergeFromFiles")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task MergeFromFiles(
[FromForm] List files,
CancellationToken cancellationToken)
{
// Convert IFormFile[] to Stream[] (use OpenReadStream directly - no buffering)
var streams = files.Select(f => f.OpenReadStream()).ToList();
// Send command to MediatR (no page ranges for now - multipart binding is complex)
var command = new MergePdfsCommand
{
PdfStreams = streams,
PageRanges = null
};
byte[] mergedPdf = await mediator.Send(command, cancellationToken);
// Return merged PDF
return File(mergedPdf, "application/pdf", "merged.pdf");
}
///
/// Merges multiple PDF files into a single PDF.
/// Supports Base64-encoded PDFs via JSON payload.
///
/// Request containing Base64-encoded PDFs and optional page ranges
/// Cancellation token
/// Merged PDF file
/// PDFs merged successfully - returns merged PDF
/// Invalid input (Base64 format error, fewer than 2 files, corrupted PDF, invalid page range)
/// Internal server error during PDF processing
[HttpPost("merge", Name = "MergeFromBase64")]
[Consumes("application/json")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task MergeFromBase64(
[FromBody] MergePdfsBase64Request request,
CancellationToken cancellationToken)
{
// Convert Base64[] to MemoryStream[]
List streams = [];
try
{
foreach (var base64Pdf in request.Base64Pdfs)
{
byte[] pdfBytes = Convert.FromBase64String(base64Pdf);
streams.Add(new MemoryStream(pdfBytes));
}
}
catch (FormatException ex)
{
// Dispose opened streams on error
foreach (var stream in streams) stream.Dispose();
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
var command = new MergePdfsCommand
{
PdfStreams = streams,
PageRanges = request.PageRanges
};
byte[] mergedPdf = await mediator.Send(command, cancellationToken);
// Cleanup streams (important for MemoryStreams we created)
foreach (var stream in streams) stream.Dispose();
return File(mergedPdf, "application/pdf", "merged.pdf");
}
///
/// Adds an annotation to a PDF document.
/// Supports multipart/form-data file upload.
///
/// Multipart form data containing PDF file and annotation parameters
/// Cancellation token
/// Annotated PDF file
/// Annotation added successfully - returns annotated PDF
/// Invalid input (invalid page number, missing required parameters)
/// Internal server error during PDF processing
[HttpPost("annotate", Name = "AnnotateFromFile")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task AnnotateFromFile(
[FromForm] AddAnnotationMultipartRequest request,
CancellationToken cancellationToken)
{
// Calculate X2, Y2 from Width/Height if provided
double x2 = request.X2 ?? request.X1 + (request.Width ?? throw new BadRequestException("Either X2 or Width must be provided"));
double y2 = request.Y2 ?? request.Y1 + (request.Height ?? throw new BadRequestException("Either Y2 or Height must be provided"));
var command = new AddAnnotationCommand
{
PdfStream = request.File.OpenReadStream(),
AnnotationType = request.AnnotationType,
PageNumber = request.PageNumber,
Rectangle = (request.X1, request.Y1, x2, y2),
Content = request.Content,
Author = request.Author,
Color = request.Color,
TextMarkupStyle = request.TextMarkupStyle,
Origin = request.Origin
};
byte[] annotatedPdf = await mediator.Send(command, cancellationToken);
return File(annotatedPdf, "application/pdf", "annotated.pdf");
}
///
/// Adds an annotation to a PDF document.
/// Supports Base64-encoded PDF via JSON payload.
///
/// Command containing all annotation parameters (including Base64 PDF)
/// Cancellation token
/// Annotated PDF file
/// Annotation added successfully - returns annotated PDF
/// Invalid input (Base64 format error, invalid page number, missing required parameters)
/// Internal server error during PDF processing
[HttpPost("annotate", Name = "AnnotateFromBase64")]
[Consumes("application/json")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task AnnotateFromBase64(
[FromBody] AddAnnotationBase64Command command,
CancellationToken cancellationToken)
{
// Convert Base64 to MemoryStream
Stream pdfStream;
try
{
byte[] pdfBytes = Convert.FromBase64String(command.Base64Pdf);
pdfStream = new MemoryStream(pdfBytes);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 format: " + ex.Message);
}
// Calculate X2, Y2 from Width/Height if provided
double x2 = command.X2 ?? command.X1 + (command.Width ?? throw new BadRequestException("Either X2 or Width must be provided"));
double y2 = command.Y2 ?? command.Y1 + (command.Height ?? throw new BadRequestException("Either Y2 or Height must be provided"));
var annotationCommand = new AddAnnotationCommand
{
PdfStream = pdfStream,
AnnotationType = command.AnnotationType,
PageNumber = command.PageNumber,
Rectangle = (command.X1, command.Y1, x2, y2),
Content = command.Content,
Author = command.Author,
Color = command.Color,
TextMarkupStyle = command.TextMarkupStyle,
Origin = command.Origin
};
byte[] annotatedPdf = await mediator.Send(annotationCommand, cancellationToken);
// Cleanup stream
pdfStream.Dispose();
return File(annotatedPdf, "application/pdf", "annotated.pdf");
}
///
/// Adds a stamp (text, image, or predefined) to PDF pages.
/// Supports multipart/form-data file upload.
///
/// Multipart form data containing PDF file and stamp parameters
/// Cancellation token
/// Stamped PDF file
/// Stamp added successfully - returns stamped PDF
/// Invalid input (invalid page number, missing required parameters, invalid image format)
/// Internal server error during PDF processing
[HttpPost("stamp", Name = "AddStampFromFile")]
[Consumes("multipart/form-data")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task AddStampFromFile(
[FromForm] AddStampMultipartRequest request,
CancellationToken cancellationToken)
{
// Convert ImageFile to byte[] if provided
byte[]? imageBytes = null;
if (request.ImageFile != null)
{
using var ms = new MemoryStream();
await request.ImageFile.CopyToAsync(ms, cancellationToken);
imageBytes = ms.ToArray();
}
var command = new AddStampCommand
{
PdfStream = request.File.OpenReadStream(),
StampType = request.StampType,
PageNumbers = request.PageNumbers,
Position = (request.X, request.Y),
Size = request.Width.HasValue && request.Height.HasValue
? (request.Width.Value, request.Height.Value)
: null,
Origin = request.Origin,
Text = request.Text,
FontName = request.FontName,
FontSize = request.FontSize,
Color = request.Color,
Opacity = request.Opacity,
Rotation = request.Rotation,
Placement = request.Placement,
ImageBytes = imageBytes,
PredefinedType = request.PredefinedType
};
byte[] stampedPdf = await mediator.Send(command, cancellationToken);
return File(stampedPdf, "application/pdf", "stamped.pdf");
}
///
/// Adds a stamp (text, image, or predefined) to PDF pages.
/// Supports Base64-encoded PDF and image via JSON payload.
///
/// Request containing Base64-encoded PDF, stamp parameters, and optional Base64 image
/// Cancellation token
/// Stamped PDF file
/// Stamp added successfully - returns stamped PDF
/// Invalid input (Base64 format error, invalid page number, missing required parameters)
/// Internal server error during PDF processing
[HttpPost("stamp", Name = "AddStampFromBase64")]
[Consumes("application/json")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
public async Task AddStampFromBase64(
[FromBody] AddStampBase64Request request,
CancellationToken cancellationToken)
{
// Convert Base64 PDF to MemoryStream
Stream pdfStream;
try
{
byte[] pdfBytes = Convert.FromBase64String(request.Base64Pdf);
pdfStream = new MemoryStream(pdfBytes);
}
catch (FormatException ex)
{
throw new BadRequestException("Invalid Base64 PDF format: " + ex.Message);
}
// Convert Base64 image to byte[] if provided
byte[]? imageBytes = null;
if (!string.IsNullOrWhiteSpace(request.Base64Image))
{
try
{
imageBytes = Convert.FromBase64String(request.Base64Image);
}
catch (FormatException ex)
{
pdfStream.Dispose();
throw new BadRequestException("Invalid Base64 image format: " + ex.Message);
}
}
var command = new AddStampCommand
{
PdfStream = pdfStream,
StampType = request.StampType,
PageNumbers = request.PageNumbers,
Position = (request.X, request.Y),
Size = request.Width.HasValue && request.Height.HasValue
? (request.Width.Value, request.Height.Value)
: null,
Origin = request.Origin,
Text = request.Text,
FontName = request.FontName,
FontSize = request.FontSize,
Color = request.Color,
Opacity = request.Opacity,
Rotation = request.Rotation,
Placement = request.Placement,
ImageBytes = imageBytes,
PredefinedType = request.PredefinedType
};
byte[] stampedPdf = await mediator.Send(command, cancellationToken);
// Cleanup stream
pdfStream.Dispose();
return File(stampedPdf, "application/pdf", "stamped.pdf");
}
}
///
/// Request DTO for multipart/form-data annotation operation
///
public class AddAnnotationMultipartRequest
{
///
/// PDF file to annotate
///
public required IFormFile File { get; set; }
///
/// Type of annotation (TextMarkup, FreeText, StickyNote, Circle, Square)
///
public required AnnotationType AnnotationType { get; set; }
///
/// Target page number (1-indexed)
///
public required int PageNumber { get; set; }
///
/// Rectangle X1 coordinate (left)
///
public required double X1 { get; set; }
///
/// Rectangle Y1 coordinate (top or bottom depending on Origin)
///
public required double Y1 { get; set; }
///
/// Rectangle X2 coordinate (right). Optional if Width is provided.
///
public double? X2 { get; set; }
///
/// Rectangle Y2 coordinate (bottom or top depending on Origin). Optional if Height is provided.
///
public double? Y2 { get; set; }
///
/// Rectangle width. Alternative to X2 (X2 = X1 + Width). Optional if X2 is provided.
///
public double? Width { get; set; }
///
/// Rectangle height. Alternative to Y2 (Y2 = Y1 + Height). Optional if Y2 is provided.
///
public double? Height { get; set; }
///
/// Annotation content (required for FreeText/StickyNote)
///
public string? Content { get; set; }
///
/// Author name (optional)
///
public string? Author { get; set; }
///
/// Hex color (6 digits, e.g., "FF0000" for red)
///
public string? Color { get; set; }
///
/// Markup style (Highlight/Underline/Strikeout, required for TextMarkup)
///
public TextMarkupStyle? TextMarkupStyle { get; set; }
///
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
///
public AnnotationOrigin Origin { get; set; } = AnnotationOrigin.BottomLeft;
}
///
/// Request DTO for Base64-encoded PDF merge operation (API layer only - converts to MergePdfsCommand)
///
public record MergePdfsBase64Request
{
///
/// Array of Base64-encoded PDF files (minimum 2 required)
///
/// ["JVBERi0xLjQK...", "JVBERi0xLjQK..."]
public required List Base64Pdfs { get; init; }
///
/// Optional page ranges per PDF (null = all pages).
/// Format: "1-3,5" means pages 1, 2, 3, and 5.
/// If provided, array length must match Base64Pdfs length.
///
/// ["1-2", "1,3,5", null]
public List? PageRanges { get; init; }
}
///
/// Request DTO for Base64-encoded PDF annotation (API layer only - converts to AddAnnotationCommand)
///
public record AddAnnotationBase64Command
{
///
/// Base64-encoded PDF file
///
/// "JVBERi0xLjQK..."
public required string Base64Pdf { get; init; }
///
/// Type of annotation to add
///
/// TextMarkup
public required AnnotationType AnnotationType { get; init; }
///
/// Target page number (1-indexed)
///
/// 1
public required int PageNumber { get; init; }
///
/// Rectangle X1 coordinate (left)
///
/// 100.0
public required double X1 { get; init; }
///
/// Rectangle Y1 coordinate (top or bottom depending on Origin)
///
/// 100.0
public required double Y1 { get; init; }
///
/// Rectangle X2 coordinate (right). Optional if Width is provided.
///
/// 200.0
public double? X2 { get; init; }
///
/// Rectangle Y2 coordinate (bottom or top depending on Origin). Optional if Height is provided.
///
/// 120.0
public double? Y2 { get; init; }
///
/// Rectangle width. Alternative to X2 (X2 = X1 + Width). Optional if X2 is provided.
///
/// 100.0
public double? Width { get; init; }
///
/// Rectangle height. Alternative to Y2 (Y2 = Y1 + Height). Optional if Y2 is provided.
///
/// 20.0
public double? Height { get; init; }
///
/// Annotation content (required for FreeText and StickyNote)
///
/// "Important text to highlight"
public string? Content { get; init; }
///
/// Author name (optional)
///
/// "John Doe"
public string? Author { get; init; }
///
/// Hex color (6 digits, e.g., "FF0000" for red). Optional - defaults vary by annotation type.
///
/// "FFFF00"
public string? Color { get; init; }
///
/// Text markup style (Highlight, Underline, or Strikeout). Required for TextMarkup annotations.
///
/// Highlight
public TextMarkupStyle? TextMarkupStyle { get; init; }
///
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
///
/// BottomLeft
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
}
///
/// Request DTO for multipart/form-data stamp operation
///
public class AddStampMultipartRequest
{
///
/// PDF file to stamp
///
public required IFormFile File { get; set; }
///
/// Type of stamp (Text, Image, or Predefined)
///
public required StampType StampType { get; set; }
///
/// Target page numbers (1-indexed). Null or empty = all pages.
///
/// [1, 3, 5]
public int[]? PageNumbers { get; set; }
///
/// Stamp position X coordinate
///
/// 100.0
public required double X { get; set; }
///
/// Stamp position Y coordinate
///
/// 100.0
public required double Y { get; set; }
///
/// Stamp width (optional, auto-size for images if not specified)
///
/// 200.0
public double? Width { get; set; }
///
/// Stamp height (optional, auto-size for images if not specified)
///
/// 50.0
public double? Height { get; set; }
///
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
///
/// BottomLeft
public AnnotationOrigin Origin { get; set; } = AnnotationOrigin.BottomLeft;
///
/// Text content (required for Text stamps)
///
/// "CONFIDENTIAL"
public string? Text { get; set; }
///
/// Font name (default: Arial)
///
/// "Arial"
public string? FontName { get; set; }
///
/// Font size in points (default: 12)
///
/// 24.0
public double? FontSize { get; set; }
///
/// Hex color (6 digits, e.g., "FF0000" for red, default: "000000")
///
/// "FF0000"
public string? Color { get; set; }
///
/// Opacity (0.0 = transparent, 1.0 = opaque, default: 0.5)
///
/// 0.5
public double? Opacity { get; set; }
///
/// Rotation angle in degrees (0-360, default: 0)
///
/// 45.0
public double? Rotation { get; set; }
///
/// Stamp placement (Foreground = on top, Background = watermark effect, default: Foreground)
///
/// Foreground
public StampPlacement Placement { get; set; } = StampPlacement.Foreground;
///
/// Image file (required for Image stamps, PNG/JPEG)
///
public IFormFile? ImageFile { get; set; }
///
/// Predefined stamp type (required for Predefined stamps)
///
/// Confidential
public PredefinedStampType? PredefinedType { get; set; }
}
///
/// Request DTO for Base64-encoded PDF stamp operation (API layer only - converts to AddStampCommand)
///
public record AddStampBase64Request
{
///
/// Base64-encoded PDF file
///
/// "JVBERi0xLjQK..."
public required string Base64Pdf { get; init; }
///
/// Type of stamp (Text, Image, or Predefined)
///
/// Text
public required StampType StampType { get; init; }
///
/// Target page numbers (1-indexed). Null or empty = all pages.
///
/// [1, 3, 5]
public int[]? PageNumbers { get; init; }
///
/// Stamp position X coordinate
///
/// 100.0
public required double X { get; init; }
///
/// Stamp position Y coordinate
///
/// 100.0
public required double Y { get; init; }
///
/// Stamp width (optional, auto-size for images if not specified)
///
/// 200.0
public double? Width { get; init; }
///
/// Stamp height (optional, auto-size for images if not specified)
///
/// 50.0
public double? Height { get; init; }
///
/// Coordinate origin (BottomLeft = PDF native, TopLeft = UI-friendly). Default: BottomLeft
///
/// BottomLeft
public AnnotationOrigin Origin { get; init; } = AnnotationOrigin.BottomLeft;
///
/// Text content (required for Text stamps)
///
/// "CONFIDENTIAL"
public string? Text { get; init; }
///
/// Font name (default: Arial)
///
/// "Arial"
public string? FontName { get; init; }
///
/// Font size in points (default: 12)
///
/// 24.0
public double? FontSize { get; init; }
///
/// Hex color (6 digits, e.g., "FF0000" for red, default: "000000")
///
/// "FF0000"
public string? Color { get; init; }
///
/// Opacity (0.0 = transparent, 1.0 = opaque, default: 0.5)
///
/// 0.5
public double? Opacity { get; init; }
///
/// Rotation angle in degrees (0-360, default: 0)
///
/// 45.0
public double? Rotation { get; init; }
///
/// Stamp placement (Foreground = on top, Background = watermark effect, default: Foreground)
///
/// Foreground
public StampPlacement Placement { get; init; } = StampPlacement.Foreground;
///
/// Base64-encoded image (required for Image stamps, PNG/JPEG)
///
/// "iVBORw0KGgoAAAANSUhEUgAA..."
public string? Base64Image { get; init; }
///
/// Predefined stamp type (required for Predefined stamps)
///
/// Confidential
public PredefinedStampType? PredefinedType { get; init; }
}