Add PDF attachment and PDF/A conversion features

Introduced endpoints for embedding attachments in PDFs and converting
between standard PDFs and PDF/A formats. Added `PdfAttachmentController`
and `PdfConversionController` with multipart/form-data and Base64-based
support. Implemented commands, handlers, and validators for these
operations.

Extended `IPdfProcessor` with methods for adding attachments and
PDF/A conversion. Partially implemented functionality in
`DevExpressPdfProcessor`, including `ConvertFromPdfAAsync`.

Added `attachment.xml` and `withoutAttachment.pdf` as resources for
testing. Marked endpoints as `[Obsolete]` to indicate incomplete
implementation. Improved validation and error handling for commands.
This commit is contained in:
2026-07-30 13:43:55 +02:00
parent a242458d2f
commit a6694bfce7
9 changed files with 876 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
using DocumentOperator.Application.Common.Interfaces;
using FluentValidation;
using MediatR;
namespace DocumentOperator.Application.AddAttachments;
/// <summary>
/// Command to add one or more attachments to a PDF document (supports PDF/A-3)
/// </summary>
public record AddAttachmentsCommand : IRequest<byte[]>
{
/// <summary>
/// PDF document stream. Must be positioned at the beginning (Position = 0).
/// </summary>
public required Stream PdfStream { get; init; }
/// <summary>
/// List of attachments to embed (filename, content, optional MIME type)
/// </summary>
public required IReadOnlyList<AttachmentFile> Attachments { get; init; }
}
/// <summary>
/// Represents a file to be attached to a PDF
/// </summary>
public record AttachmentFile
{
/// <summary>
/// File name (e.g., "invoice.xml", "document.pdf")
/// </summary>
public required string FileName { get; init; }
/// <summary>
/// File content as byte array
/// </summary>
public required byte[] Content { get; init; }
/// <summary>
/// MIME type (optional, e.g., "application/xml", "application/pdf")
/// If not provided, will be inferred from file extension
/// </summary>
public string? MimeType { get; init; }
}
/// <summary>
/// Handler for AddAttachmentsCommand
/// </summary>
public class AddAttachmentsCommandHandler(IPdfProcessor pdfProcessor)
: IRequestHandler<AddAttachmentsCommand, byte[]>
{
public async Task<byte[]> Handle(AddAttachmentsCommand request, CancellationToken cancellationToken)
{
// Convert to tuple list for IPdfProcessor
var attachmentTuples = request.Attachments
.Select(a => (a.FileName, a.Content, a.MimeType))
.ToList();
return await pdfProcessor.AddAttachmentsAsync(request.PdfStream, attachmentTuples);
}
}
/// <summary>
/// Validator for AddAttachmentsCommand
/// </summary>
public class AddAttachmentsCommandValidator : AbstractValidator<AddAttachmentsCommand>
{
public AddAttachmentsCommandValidator()
{
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PDF stream is required");
RuleFor(x => x.Attachments)
.NotNull()
.NotEmpty()
.WithMessage("At least one attachment is required");
RuleForEach(x => x.Attachments).ChildRules(attachment =>
{
attachment.RuleFor(a => a.FileName)
.NotEmpty()
.WithMessage("Attachment file name is required");
attachment.RuleFor(a => a.Content)
.NotNull()
.NotEmpty()
.WithMessage("Attachment content is required");
});
}
}

View File

@@ -145,4 +145,47 @@ public interface IPdfProcessor
Domain.Models.ValueObjects.StampPlacement placement = Domain.Models.ValueObjects.StampPlacement.Foreground,
byte[]? imageBytes = null,
Domain.Models.ValueObjects.PredefinedStampType? predefinedType = null);
/// <summary>
/// Embeds one or more files as attachments in a PDF document (supports PDF/A-3).
/// </summary>
/// <param name="pdfStream">
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <param name="attachments">List of files to embed (filename, content, optional MIME type)</param>
/// <returns>PDF with embedded attachments as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty/invalid, or attachments list is empty
/// </exception>
Task<byte[]> AddAttachmentsAsync(
Stream pdfStream,
IReadOnlyList<(string FileName, byte[] Content, string? MimeType)> attachments);
/// <summary>
/// Converts a standard PDF to PDF/A format.
/// </summary>
/// <param name="pdfStream">
/// PDF document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <param name="pdfALevel">Target PDF/A level (e.g., "PDF/A-1b", "PDF/A-2b", "PDF/A-3b")</param>
/// <returns>PDF/A compliant document as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty/invalid or PDF/A level is unsupported
/// </exception>
Task<byte[]> ConvertToPdfAAsync(Stream pdfStream, string pdfALevel);
/// <summary>
/// Converts a PDF/A document to a standard PDF (removes PDF/A restrictions).
/// </summary>
/// <param name="pdfStream">
/// PDF/A document stream. Must be readable and positioned at the beginning (Position = 0).
/// Non-seekable streams are supported. Caller is responsible for disposal.
/// </param>
/// <returns>Standard PDF document as byte array</returns>
/// <exception cref="Domain.Common.Exceptions.BadRequestException">
/// Thrown when stream is empty/invalid
/// </exception>
Task<byte[]> ConvertFromPdfAAsync(Stream pdfStream);
}

View File

@@ -0,0 +1,41 @@
using DocumentOperator.Application.Common.Interfaces;
using FluentValidation;
using MediatR;
namespace DocumentOperator.Application.ConvertFromPdfA;
/// <summary>
/// Command to convert a PDF/A document to a standard PDF (removes PDF/A restrictions)
/// </summary>
public record ConvertFromPdfACommand : IRequest<byte[]>
{
/// <summary>
/// PDF/A document stream. Must be positioned at the beginning (Position = 0).
/// </summary>
public required Stream PdfStream { get; init; }
}
/// <summary>
/// Handler for ConvertFromPdfACommand
/// </summary>
public class ConvertFromPdfACommandHandler(IPdfProcessor pdfProcessor)
: IRequestHandler<ConvertFromPdfACommand, byte[]>
{
public async Task<byte[]> Handle(ConvertFromPdfACommand request, CancellationToken cancellationToken)
{
return await pdfProcessor.ConvertFromPdfAAsync(request.PdfStream);
}
}
/// <summary>
/// Validator for ConvertFromPdfACommand
/// </summary>
public class ConvertFromPdfACommandValidator : AbstractValidator<ConvertFromPdfACommand>
{
public ConvertFromPdfACommandValidator()
{
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PDF stream is required");
}
}

View File

@@ -0,0 +1,59 @@
using DocumentOperator.Application.Common.Interfaces;
using FluentValidation;
using MediatR;
namespace DocumentOperator.Application.ConvertToPdfA;
/// <summary>
/// Command to convert a standard PDF to PDF/A format
/// </summary>
public record ConvertToPdfACommand : IRequest<byte[]>
{
/// <summary>
/// PDF document stream. Must be positioned at the beginning (Position = 0).
/// </summary>
public required Stream PdfStream { get; init; }
/// <summary>
/// Target PDF/A level (e.g., "PDF/A-1b", "PDF/A-2b", "PDF/A-3b")
/// </summary>
public required string PdfALevel { get; init; }
}
/// <summary>
/// Handler for ConvertToPdfACommand
/// </summary>
public class ConvertToPdfACommandHandler(IPdfProcessor pdfProcessor)
: IRequestHandler<ConvertToPdfACommand, byte[]>
{
public async Task<byte[]> Handle(ConvertToPdfACommand request, CancellationToken cancellationToken)
{
return await pdfProcessor.ConvertToPdfAAsync(request.PdfStream, request.PdfALevel);
}
}
/// <summary>
/// Validator for ConvertToPdfACommand
/// </summary>
public class ConvertToPdfACommandValidator : AbstractValidator<ConvertToPdfACommand>
{
private static readonly string[] ValidPdfALevels =
{
"PDF/A-1b", "PDF/A-1a",
"PDF/A-2b", "PDF/A-2u", "PDF/A-2a",
"PDF/A-3b", "PDF/A-3u", "PDF/A-3a"
};
public ConvertToPdfACommandValidator()
{
RuleFor(x => x.PdfStream)
.NotNull()
.WithMessage("PDF stream is required");
RuleFor(x => x.PdfALevel)
.NotEmpty()
.WithMessage("PDF/A level is required")
.Must(level => ValidPdfALevels.Contains(level, StringComparer.OrdinalIgnoreCase))
.WithMessage($"Invalid PDF/A level. Valid values: {string.Join(", ", ValidPdfALevels)}");
}
}