Pārlūkot izejas kodu

Implement comprehensive stream-based DocumentOperation API

Co-authored-by: MarcinZiabek <[email protected]>
copilot-swe-agent[bot] 6 mēneši atpakaļ
vecāks
revīzija
37c85d1e4b

+ 206 - 0
Source/QuestPDF.DocumentationExamples/DocumentOperationStreamExamples.cs

@@ -0,0 +1,206 @@
+using System.IO;
+using System.Runtime.InteropServices;
+using NUnit.Framework;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.DocumentationExamples;
+
+[TestFixture]
+public class DocumentOperationStreamExamples
+{
+    public DocumentOperationStreamExamples()
+    {
+        if (RuntimeInformation.RuntimeIdentifier == "linux-musl-x64")
+            Assert.Ignore("The DocumentOperations functionality is not supported on Linux Musl, e.g. Alpine.");
+    }
+    
+    [Test]
+    public void LoadFromStreamAndSaveToStream()
+    {
+        // Generate a sample PDF as bytes
+        var samplePdfBytes = Document
+            .Create(document =>
+            {
+                document.Page(page =>
+                {
+                    page.Size(PageSizes.A4);
+                    page.Content()
+                        .PaddingVertical(1, Unit.Centimetre)
+                        .PaddingHorizontal(2, Unit.Centimetre)
+                        .Column(column =>
+                        {
+                            column.Item().Text("Sample Document").FontSize(20).Bold();
+                            column.Item().Text("This document was loaded from a stream and processed using DocumentOperation.").FontSize(12);
+                        });
+                });
+            })
+            .GeneratePdf();
+
+        // Process the PDF using stream-based operations
+        using var inputStream = new MemoryStream(samplePdfBytes);
+        using var outputStream = new MemoryStream();
+        
+        DocumentOperation
+            .LoadStream(inputStream)
+            .Linearize() // Optimize for web
+            .SaveToStream(outputStream);
+            
+        // The processed PDF is now in outputStream
+        Assert.That(outputStream.Length, Is.GreaterThan(0));
+        
+        // You can write it to a file if needed
+        File.WriteAllBytes("stream-processed-output.pdf", outputStream.ToArray());
+    }
+
+    [Test]
+    public void MergeMultipleStreams()
+    {
+        // Create multiple PDF documents as byte arrays
+        var doc1Bytes = CreateSampleDocument("Document 1", Colors.Red.Lighten3);
+        var doc2Bytes = CreateSampleDocument("Document 2", Colors.Green.Lighten3);
+        var doc3Bytes = CreateSampleDocument("Document 3", Colors.Blue.Lighten3);
+
+        // Merge them using streams
+        using var stream1 = new MemoryStream(doc1Bytes);
+        using var stream2 = new MemoryStream(doc2Bytes);
+        using var stream3 = new MemoryStream(doc3Bytes);
+        using var outputStream = new MemoryStream();
+        
+        DocumentOperation
+            .LoadStream(stream1)
+            .MergeStream(stream2)
+            .MergeStream(stream3)
+            .SaveToStream(outputStream);
+            
+        // Save the merged document
+        File.WriteAllBytes("merged-streams-output.pdf", outputStream.ToArray());
+        
+        Assert.That(outputStream.Length, Is.GreaterThan(0));
+    }
+
+    [Test]
+    public void AddStreamBasedOverlay()
+    {
+        // Create main document and watermark
+        var mainDocBytes = CreateSampleDocument("Main Document", Colors.Grey.Lighten4);
+        var watermarkBytes = CreateWatermarkDocument();
+
+        using var mainStream = new MemoryStream(mainDocBytes);
+        using var watermarkStream = new MemoryStream(watermarkBytes);
+        using var outputStream = new MemoryStream();
+        
+        DocumentOperation
+            .LoadStream(mainStream)
+            .OverlayStream(new DocumentOperation.LayerStreamConfiguration
+            {
+                Stream = watermarkStream
+            })
+            .SaveToStream(outputStream);
+            
+        File.WriteAllBytes("watermarked-output.pdf", outputStream.ToArray());
+        
+        Assert.That(outputStream.Length, Is.GreaterThan(0));
+    }
+
+    [Test]
+    public void AddAttachmentFromStream()
+    {
+        // Create main document
+        var mainDocBytes = CreateSampleDocument("Document with Attachment", Colors.Purple.Lighten3);
+        
+        // Create attachment content
+        var attachmentContent = "This is the content of the attached text file.";
+        var attachmentBytes = System.Text.Encoding.UTF8.GetBytes(attachmentContent);
+
+        using var mainStream = new MemoryStream(mainDocBytes);
+        using var attachmentStream = new MemoryStream(attachmentBytes);
+        using var outputStream = new MemoryStream();
+        
+        DocumentOperation
+            .LoadStream(mainStream)
+            .AddAttachmentStream(new DocumentOperation.DocumentAttachmentStream
+            {
+                Stream = attachmentStream,
+                AttachmentName = "readme.txt",
+                Description = "Important information about this document",
+                MimeType = "text/plain"
+            })
+            .SaveToStream(outputStream);
+            
+        File.WriteAllBytes("document-with-attachment.pdf", outputStream.ToArray());
+        
+        Assert.That(outputStream.Length, Is.GreaterThan(0));
+    }
+
+    [Test]
+    public void MixFileAndStreamOperations()
+    {
+        // Create a file-based document first
+        var filePdfBytes = CreateSampleDocument("File-based Document", Colors.Orange.Lighten3);
+        File.WriteAllBytes("file-document.pdf", filePdfBytes);
+
+        // Create stream-based content
+        var streamPdfBytes = CreateSampleDocument("Stream-based Document", Colors.Teal.Lighten3);
+
+        using var streamInput = new MemoryStream(streamPdfBytes);
+        using var outputStream = new MemoryStream();
+        
+        // Mix file and stream operations
+        DocumentOperation
+            .LoadFile("file-document.pdf")  // Load from file
+            .MergeStream(streamInput)       // Merge from stream
+            .SaveToStream(outputStream);    // Save to stream
+            
+        File.WriteAllBytes("mixed-operations-output.pdf", outputStream.ToArray());
+        
+        Assert.That(outputStream.Length, Is.GreaterThan(0));
+    }
+
+    private byte[] CreateSampleDocument(string title, Color color)
+    {
+        return Document
+            .Create(document =>
+            {
+                document.Page(page =>
+                {
+                    page.Size(PageSizes.A4);
+                    page.PageColor(color);
+                    page.Content()
+                        .PaddingVertical(2, Unit.Centimetre)
+                        .PaddingHorizontal(2, Unit.Centimetre)
+                        .Column(column =>
+                        {
+                            column.Item().Text(title).FontSize(24).Bold().FontColor(Colors.White);
+                            column.Item().PaddingTop(1, Unit.Centimetre);
+                            column.Item().Text("This is a sample document created for stream-based operations demonstration.")
+                                .FontSize(12).FontColor(Colors.White);
+                        });
+                });
+            })
+            .GeneratePdf();
+    }
+
+    private byte[] CreateWatermarkDocument()
+    {
+        return Document
+            .Create(document =>
+            {
+                document.Page(page =>
+                {
+                    page.Size(PageSizes.A4);
+                    page.PageColor(Colors.Transparent);
+                    page.Content()
+                        .AlignCenter()
+                        .AlignMiddle()
+                        .Rotate(-45)
+                        .Text("CONFIDENTIAL")
+                        .FontSize(72)
+                        .FontColor(Colors.Red.Darken2)
+                        .Bold();
+                });
+            })
+            .GeneratePdf();
+    }
+}

+ 264 - 0
Source/QuestPDF.UnitTests/DocumentOperationStreamTests.cs

@@ -0,0 +1,264 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using NUnit.Framework;
+using QuestPDF.Fluent;
+using QuestPDF.Helpers;
+using QuestPDF.Infrastructure;
+
+namespace QuestPDF.UnitTests;
+
+/// <summary>
+/// This test suite focuses on executing various QPDF operations using stream-based APIs.
+/// In most cases, it does not check the result but rather if any exception is thrown.
+/// </summary>
+public class DocumentOperationStreamTests
+{
+    public DocumentOperationStreamTests()
+    {
+        if (RuntimeInformation.RuntimeIdentifier == "linux-musl-x64")
+            Assert.Ignore("The DocumentOperations functionality is not supported on Linux Musl, e.g. Alpine.");
+    }
+    
+    [Test]
+    public void LoadStreamTest()
+    {
+        var sourceBytes = GenerateSampleDocumentBytes("load-stream-source.pdf", Colors.Red.Medium, 5);
+        
+        using var sourceStream = new MemoryStream(sourceBytes);
+        using var outputStream = new MemoryStream();
+        
+        DocumentOperation
+            .LoadStream(sourceStream)
+            .SaveToStream(outputStream);
+            
+        Assert.That(outputStream.Length, Is.GreaterThan(0));
+    }
+    
+    [Test]
+    public void MergeStreamTest()
+    {
+        var firstBytes = GenerateSampleDocumentBytes("merge-stream-first.pdf", Colors.Red.Medium, 3);
+        var secondBytes = GenerateSampleDocumentBytes("merge-stream-second.pdf", Colors.Green.Medium, 5);
+        var thirdBytes = GenerateSampleDocumentBytes("merge-stream-third.pdf", Colors.Blue.Medium, 7);
+        
+        using var firstStream = new MemoryStream(firstBytes);
+        using var secondStream = new MemoryStream(secondBytes);
+        using var thirdStream = new MemoryStream(thirdBytes);
+        using var outputStream = new MemoryStream();
+        
+        DocumentOperation
+            .LoadStream(firstStream)
+            .MergeStream(secondStream)
+            .MergeStream(thirdStream)
+            .SaveToStream(outputStream);
+            
+        Assert.That(outputStream.Length, Is.GreaterThan(0));
+    }
+    
+    [Test]
+    public void OverlayStreamTest()
+    {
+        var mainBytes = GenerateSampleDocumentBytes("overlay-stream-main.pdf", Colors.Red.Medium, 10);
+        var watermarkBytes = GenerateSampleDocumentBytes("overlay-stream-watermark.pdf", Colors.Green.Medium, 5);
+        
+        using var mainStream = new MemoryStream(mainBytes);
+        using var watermarkStream = new MemoryStream(watermarkBytes);
+        using var outputStream = new MemoryStream();
+        
+        DocumentOperation
+            .LoadStream(mainStream)
+            .OverlayStream(new DocumentOperation.LayerStreamConfiguration
+            {
+                Stream = watermarkStream
+            })
+            .SaveToStream(outputStream);
+            
+        Assert.That(outputStream.Length, Is.GreaterThan(0));
+    }
+    
+    [Test]
+    public void UnderlayStreamTest()
+    {
+        var mainBytes = GenerateSampleDocumentBytes("underlay-stream-main.pdf", Colors.Red.Medium, 10);
+        var watermarkBytes = GenerateSampleDocumentBytes("underlay-stream-watermark.pdf", Colors.Green.Medium, 5);
+        
+        using var mainStream = new MemoryStream(mainBytes);
+        using var watermarkStream = new MemoryStream(watermarkBytes);
+        using var outputStream = new MemoryStream();
+        
+        DocumentOperation
+            .LoadStream(mainStream)
+            .UnderlayStream(new DocumentOperation.LayerStreamConfiguration
+            {
+                Stream = watermarkStream,
+            })
+            .SaveToStream(outputStream);
+            
+        Assert.That(outputStream.Length, Is.GreaterThan(0));
+    }
+
+    [Test]
+    public void AttachmentStreamTest()
+    {
+        var mainBytes = GenerateSampleDocumentBytes("attachment-stream-main.pdf", Colors.Red.Medium, 10);
+        var attachmentBytes = GenerateSampleDocumentBytes("attachment-stream-file.pdf", Colors.Green.Medium, 5);
+        
+        using var mainStream = new MemoryStream(mainBytes);
+        using var attachmentStream = new MemoryStream(attachmentBytes);
+        using var outputStream = new MemoryStream();
+        
+        DocumentOperation
+            .LoadStream(mainStream)
+            .AddAttachmentStream(new DocumentOperation.DocumentAttachmentStream
+            {
+                Stream = attachmentStream,
+                AttachmentName = "attached-document.pdf"
+            })
+            .SaveToStream(outputStream);
+            
+        Assert.That(outputStream.Length, Is.GreaterThan(0));
+    }
+    
+    [Test]
+    public void MixedFileAndStreamOperationsTest()
+    {
+        // Create a file-based document
+        GenerateSampleDocument("mixed-file-input.pdf", Colors.Red.Medium, 5);
+        
+        // Create stream-based documents
+        var mergeBytes = GenerateSampleDocumentBytes("mixed-stream-merge.pdf", Colors.Green.Medium, 3);
+        var overlayBytes = GenerateSampleDocumentBytes("mixed-stream-overlay.pdf", Colors.Blue.Medium, 2);
+        
+        using var mergeStream = new MemoryStream(mergeBytes);
+        using var overlayStream = new MemoryStream(overlayBytes);
+        using var outputStream = new MemoryStream();
+        
+        DocumentOperation
+            .LoadFile("mixed-file-input.pdf")
+            .MergeStream(mergeStream)
+            .OverlayStream(new DocumentOperation.LayerStreamConfiguration
+            {
+                Stream = overlayStream
+            })
+            .SaveToStream(outputStream);
+            
+        Assert.That(outputStream.Length, Is.GreaterThan(0));
+    }
+    
+    [Test]
+    public void LoadStreamWithPasswordTest()
+    {
+        // Create an encrypted PDF first
+        var sourceBytes = GenerateSampleDocumentBytes("encrypted-stream-source.pdf", Colors.Red.Medium, 5);
+        
+        using var sourceStream = new MemoryStream(sourceBytes);
+        using var encryptedStream = new MemoryStream();
+        
+        DocumentOperation
+            .LoadStream(sourceStream)
+            .Encrypt(new DocumentOperation.Encryption256Bit()
+            {
+                UserPassword = "user_password",
+                OwnerPassword = "owner_password"
+            })
+            .SaveToStream(encryptedStream);
+            
+        // Now try to load the encrypted stream
+        encryptedStream.Position = 0;
+        using var outputStream = new MemoryStream();
+        
+        DocumentOperation
+            .LoadStream(encryptedStream, "owner_password")
+            .SaveToStream(outputStream);
+            
+        Assert.That(outputStream.Length, Is.GreaterThan(0));
+    }
+    
+    private byte[] GenerateSampleDocumentBytes(string fileName, Color color, int length)
+    {
+        return Document
+            .Create(document =>
+            {
+                document.Page(page =>
+                {
+                    page.Size(PageSizes.A4);
+                    page.PageColor(Colors.Transparent);
+                    
+                    page.Content().Column(column =>
+                    {
+                        foreach (var i in Enumerable.Range(1, length))
+                        {
+                            if (i != 1)
+                                column.Item().PageBreak();
+                            
+                            var width = Random.Shared.Next(100, 200);
+                            var height = Random.Shared.Next(100, 200);
+                            
+                            var horizontalTranslation = Random.Shared.Next(0, (int)PageSizes.A4.Width - width);
+                            var verticalTranslation = Random.Shared.Next(0, (int)PageSizes.A4.Height - height);
+                            
+                            column.Item()
+                                .TranslateX(horizontalTranslation)
+                                .TranslateY(verticalTranslation)
+                                .Width(width)
+                                .Height(height)
+                                .Background(color.WithAlpha(64))
+                                .AlignCenter()
+                                .AlignMiddle()
+                                .Text($"{fileName}\npage {i}")
+                                .FontColor(color)
+                                .Bold()
+                                .FontSize(16);
+                        }
+                    });
+                });
+            })
+            .WithSettings(new DocumentSettings { PdfA = true })
+            .GeneratePdf();
+    }
+
+    private void GenerateSampleDocument(string filePath, Color color, int length)
+    {
+        Document
+            .Create(document =>
+            {
+                document.Page(page =>
+                {
+                    page.Size(PageSizes.A4);
+                    page.PageColor(Colors.Transparent);
+                    
+                    page.Content().Column(column =>
+                    {
+                        foreach (var i in Enumerable.Range(1, length))
+                        {
+                            if (i != 1)
+                                column.Item().PageBreak();
+                            
+                            var width = Random.Shared.Next(100, 200);
+                            var height = Random.Shared.Next(100, 200);
+                            
+                            var horizontalTranslation = Random.Shared.Next(0, (int)PageSizes.A4.Width - width);
+                            var verticalTranslation = Random.Shared.Next(0, (int)PageSizes.A4.Height - height);
+                            
+                            column.Item()
+                                .TranslateX(horizontalTranslation)
+                                .TranslateY(verticalTranslation)
+                                .Width(width)
+                                .Height(height)
+                                .Background(color.WithAlpha(64))
+                                .AlignCenter()
+                                .AlignMiddle()
+                                .Text($"{filePath}\npage {i}")
+                                .FontColor(color)
+                                .Bold()
+                                .FontSize(16);
+                        }
+                    });
+                });
+            })
+            .WithSettings(new DocumentSettings { PdfA = true })
+            .GeneratePdf(filePath);
+    }
+}

+ 324 - 5
Source/QuestPDF/Fluent/DocumentOperation.cs

@@ -42,6 +42,38 @@ public sealed class DocumentOperation
         public string? RepeatSourcePages { get; set; }
     }
 
+    /// <summary>
+    /// Represents configuration options for applying an overlay or underlay to a PDF document using qpdf with stream-based input.
+    /// </summary>
+    public sealed class LayerStreamConfiguration
+    {
+        /// <summary>
+        /// The stream containing the overlay or underlay PDF data to be used.
+        /// </summary>
+        public Stream Stream { get; set; }
+
+        /// <summary>
+        /// Specifies the range of pages in the output document where the overlay or underlay will be applied.
+        /// If not specified, the overlay or underlay is applied to all output pages.
+        /// </summary>
+        /// <include file='../Resources/Documentation.xml' path='documentation/doc[@for="documentOperation.pageSelector"]/*' />
+        public string? TargetPages { get; set; }
+
+        /// <summary>
+        /// Specifies the range of pages in the overlay or underlay file to be used initially.
+        /// If not specified, all pages in the overlay or underlay file will be used in sequence.
+        /// </summary>
+        /// <include file='../Resources/Documentation.xml' path='documentation/doc[@for="documentOperation.pageSelector"]/*' />
+        public string? SourcePages { get; set; }
+
+        /// <summary>
+        /// Specifies an optional range of pages in the overlay or underlay file that will repeat after the initial source pages are exhausted.
+        /// Useful for repeating certain pages of the overlay or underlay file across multiple pages of the output.
+        /// </summary>
+        /// <include file='../Resources/Documentation.xml' path='documentation/doc[@for="documentOperation.pageSelector"]/*' />
+        public string? RepeatSourcePages { get; set; }
+    }
+
     public enum DocumentAttachmentRelationship
     {
         /// <summary>
@@ -124,6 +156,60 @@ public sealed class DocumentOperation
         public DocumentAttachmentRelationship? Relationship { get; set; } = null;
     }
 
+    public sealed class DocumentAttachmentStream
+    {
+        /// <summary>
+        /// Sets the key for the attachment, specific to the PDF format.
+        /// Defaults to the AttachmentName if provided, otherwise a generated key.
+        /// </summary>
+        public string? Key { get; set; }
+    
+        /// <summary>
+        /// The stream containing the attachment data.
+        /// </summary>
+        public Stream Stream { get; set; }
+    
+        /// <summary>
+        /// Specifies the display name for the attachment.
+        /// This name is typically shown to the user and used by most graphical PDF viewers when saving the file.
+        /// Required for stream-based attachments.
+        /// </summary>
+        public string AttachmentName { get; set; }
+    
+        /// <summary>
+        /// Specifies the creation date of the attachment. 
+        /// Defaults to the current date and time.
+        /// </summary>
+        public DateTime? CreationDate { get; set; }
+    
+        /// <summary>
+        /// Specifies the modification date of the attachment.
+        /// Defaults to the current date and time.
+        /// </summary>
+        public DateTime? ModificationDate { get; set; }
+    
+        /// <summary>
+        /// Specifies the MIME type of the attachment, such as "text/plain", "application/pdf", "image/png", etc.
+        /// </summary>
+        public string? MimeType { get; set; }
+    
+        /// <summary>
+        /// Sets a description for the attachment, which may be displayed by some PDF viewers.
+        /// </summary>
+        public string? Description { get; set; }
+    
+        /// <summary>
+        /// Indicates whether to replace an existing attachment with the same key.
+        /// If false, an exception is thrown if an attachment with the same key already exists.
+        /// </summary>
+        public bool Replace { get; set; } = true;
+        
+        /// <summary>
+        /// Specifies the relationship of the embedded file to the document for PDF/A-3b compliance.
+        /// </summary>
+        public DocumentAttachmentRelationship? Relationship { get; set; } = null;
+    }
+
     public class EncryptionBase
     {
         /// <summary>
@@ -197,12 +283,50 @@ public sealed class DocumentOperation
     }
     
     internal JobConfiguration Configuration { get; private set; }
+    private List<string> TemporaryFiles { get; } = new List<string>();
     
     private DocumentOperation()
     {
             
     }
 
+    /// <summary>
+    /// Creates a temporary file from a stream and tracks it for cleanup.
+    /// </summary>
+    private string CreateTemporaryFileFromStream(Stream stream)
+    {
+        if (stream == null)
+            throw new ArgumentNullException(nameof(stream));
+
+        var tempFilePath = Path.GetTempFileName();
+        TemporaryFiles.Add(tempFilePath);
+
+        using var fileStream = File.Create(tempFilePath);
+        stream.CopyTo(fileStream);
+        
+        return tempFilePath;
+    }
+
+    /// <summary>
+    /// Cleans up temporary files created during stream operations.
+    /// </summary>
+    private void CleanupTemporaryFiles()
+    {
+        foreach (var tempFile in TemporaryFiles)
+        {
+            try
+            {
+                if (File.Exists(tempFile))
+                    File.Delete(tempFile);
+            }
+            catch
+            {
+                // Ignore cleanup errors - files might be in use or already deleted
+            }
+        }
+        TemporaryFiles.Clear();
+    }
+
     /// <summary>
     /// Loads the specified PDF file for processing, enabling operations such as merging, overlaying or underlaying content, selecting pages, adding attachments, and encrypting.
     /// </summary>
@@ -223,6 +347,28 @@ public sealed class DocumentOperation
         };
     }
     
+    /// <summary>
+    /// Loads a PDF from the specified stream for processing, enabling operations such as merging, overlaying or underlaying content, selecting pages, adding attachments, and encrypting.
+    /// </summary>
+    /// <param name="stream">The stream containing the PDF data to be loaded.</param>
+    /// <param name="password">The password for the PDF file, if it is password-protected. Optional.</param>
+    public static DocumentOperation LoadStream(Stream stream, string? password = null)
+    {
+        if (stream == null)
+            throw new ArgumentNullException(nameof(stream));
+
+        var operation = new DocumentOperation();
+        var tempFilePath = operation.CreateTemporaryFileFromStream(stream);
+        
+        operation.Configuration = new JobConfiguration
+        {
+            InputFile = tempFilePath,
+            Password = password
+        };
+        
+        return operation;
+    }
+    
     /// <summary>
     /// Selects specific pages from the current document based on the provided page selector, marking them for further operations.
     /// </summary>
@@ -263,6 +409,31 @@ public sealed class DocumentOperation
         return this;
     }
 
+    /// <summary>
+    /// Merges pages from the specified PDF stream into the current document, according to the provided page selection.
+    /// </summary>
+    /// <param name="stream">The stream containing the PDF data to be merged.</param>
+    /// <param name="pageSelector">An optional <see cref="DocumentPageSelector"/> to specify the range of pages to merge. If not provided, all pages will be merged.</param>
+    /// <include file='../Resources/Documentation.xml' path='documentation/doc[@for="documentOperation.pageSelector"]/*' />
+    public DocumentOperation MergeStream(Stream stream, string? pageSelector = null)
+    {
+        if (stream == null)
+            throw new ArgumentNullException(nameof(stream));
+        
+        var tempFilePath = CreateTemporaryFileFromStream(stream);
+        
+        if (Configuration.Pages == null)
+            TakePages("1-z");
+        
+        Configuration.Pages.Add(new JobConfiguration.PageConfiguration
+        {
+            File = tempFilePath,
+            Range = pageSelector ?? "1-z"
+        });
+        
+        return this;
+    }
+
     /// <summary>
     /// Applies an underlay to the document using the specified configuration.
     /// The underlay pages are drawn beneath the target pages in the output file, potentially obscured by the original content.
@@ -284,6 +455,30 @@ public sealed class DocumentOperation
         
         return this;
     }
+
+    /// <summary>
+    /// Applies an underlay to the document using the specified stream-based configuration.
+    /// The underlay pages are drawn beneath the target pages in the output file, potentially obscured by the original content.
+    /// </summary>    
+    public DocumentOperation UnderlayStream(LayerStreamConfiguration configuration)
+    {
+        if (configuration?.Stream == null)
+            throw new ArgumentNullException(nameof(configuration));
+        
+        var tempFilePath = CreateTemporaryFileFromStream(configuration.Stream);
+        
+        Configuration.Underlay ??= new List<JobConfiguration.LayerConfiguration>();
+        
+        Configuration.Underlay.Add(new JobConfiguration.LayerConfiguration
+        {
+            File = tempFilePath,
+            To = configuration.TargetPages,
+            From = configuration.SourcePages,
+            Repeat = configuration.RepeatSourcePages
+        });
+        
+        return this;
+    }
     
     /// <summary>
     /// Applies an overlay to the document using the specified configuration.
@@ -307,6 +502,30 @@ public sealed class DocumentOperation
         return this;
     }
 
+    /// <summary>
+    /// Applies an overlay to the document using the specified stream-based configuration.
+    /// The overlay pages are drawn on top of the target pages in the output file, potentially obscuring the original content.
+    /// </summary>
+    public DocumentOperation OverlayStream(LayerStreamConfiguration configuration)
+    {
+        if (configuration?.Stream == null)
+            throw new ArgumentNullException(nameof(configuration));
+        
+        var tempFilePath = CreateTemporaryFileFromStream(configuration.Stream);
+        
+        Configuration.Overlay ??= new List<JobConfiguration.LayerConfiguration>();
+        
+        Configuration.Overlay.Add(new JobConfiguration.LayerConfiguration
+        {
+            File = tempFilePath,
+            To = configuration.TargetPages,
+            From = configuration.SourcePages,
+            Repeat = configuration.RepeatSourcePages
+        });
+        
+        return this;
+    }
+
     /// <summary>
     /// Extends the current document's XMP metadata by adding content within the <c>rdf:Description</c> tag.
     /// This allows for adding additional descriptive metadata to the PDF, which is useful for compliance standards
@@ -376,6 +595,63 @@ public sealed class DocumentOperation
         }
     }
 
+    /// <summary>
+    /// Adds an attachment to the document from a stream, with specified metadata and configuration options.
+    /// </summary>
+    public DocumentOperation AddAttachmentStream(DocumentAttachmentStream attachment)
+    {
+        if (attachment?.Stream == null)
+            throw new ArgumentNullException(nameof(attachment));
+        if (string.IsNullOrEmpty(attachment.AttachmentName))
+            throw new ArgumentException("AttachmentName is required for stream-based attachments.", nameof(attachment));
+
+        Configuration.AddAttachment ??= new List<JobConfiguration.AddDocumentAttachment>();
+
+        var tempFilePath = CreateTemporaryFileFromStream(attachment.Stream);
+        var now = DateTime.UtcNow;
+        
+        Configuration.AddAttachment.Add(new JobConfiguration.AddDocumentAttachment
+        {
+            Key = attachment.Key ?? attachment.AttachmentName,
+            File = tempFilePath,
+            FileName = attachment.AttachmentName,
+            CreationDate = GetFormattedDate(attachment.CreationDate, now),
+            ModificationDate = GetFormattedDate(attachment.ModificationDate, now),
+            MimeType = attachment.MimeType ?? GetDefaultMimeType(),
+            Description = attachment.Description,
+            Replace = attachment.Replace ? string.Empty : null,
+            Relationship = GetRelationship(attachment.Relationship)
+        });
+        
+        return this;
+
+        string GetDefaultMimeType()
+        {
+            var fileExtension = Path.GetExtension(attachment.AttachmentName);
+            fileExtension = fileExtension.TrimStart('.').ToLowerInvariant();
+            return MimeHelper.FileExtensionToMimeConversionTable.TryGetValue(fileExtension, out var value) ? value : "text/plain";
+        }
+        
+        string GetFormattedDate(DateTime? value, DateTime defaultValue)
+        {
+            return $"D:{(value ?? defaultValue).ToUniversalTime():yyyyMMddHHmmsss}Z";
+        }
+        
+        string? GetRelationship(DocumentAttachmentRelationship? relationship)
+        {
+            return relationship switch
+            {
+                DocumentAttachmentRelationship.Data => "/Data",
+                DocumentAttachmentRelationship.Source => "/Source",
+                DocumentAttachmentRelationship.Alternative => "/Alternative",
+                DocumentAttachmentRelationship.Supplement => "/Alternative",
+                DocumentAttachmentRelationship.Unspecified => "/Unspecified",
+                null => null,
+                _ => throw new ArgumentOutOfRangeException(nameof(relationship), relationship, null)
+            };
+        }
+    }
+
     /// <summary>
     /// Removes any existing encryption from the current PDF document, effectively making it accessible without a password or encryption restrictions.
     /// </summary>
@@ -495,11 +771,54 @@ public sealed class DocumentOperation
     /// <param name="filePath">The path where the output file will be saved.</param>
     public void Save(string filePath)
     {
-        if (File.Exists(filePath))
-            File.Delete(filePath);
+        try
+        {
+            if (File.Exists(filePath))
+                File.Delete(filePath);
+            
+            Configuration.OutputFile = filePath;
+            var json = SimpleJsonSerializer.Serialize(Configuration);
+            QpdfAPI.ExecuteJob(json);
+        }
+        finally
+        {
+            CleanupTemporaryFiles();
+        }
+    }
+
+    /// <summary>
+    /// Executes the configured operations on the document and writes the resulting PDF to the specified stream.
+    /// </summary>
+    /// <param name="stream">The stream where the output PDF data will be written.</param>
+    public void SaveToStream(Stream stream)
+    {
+        if (stream == null)
+            throw new ArgumentNullException(nameof(stream));
+
+        var tempOutputFile = Path.GetTempFileName();
         
-        Configuration.OutputFile = filePath;
-        var json = SimpleJsonSerializer.Serialize(Configuration);
-        QpdfAPI.ExecuteJob(json);
+        try
+        {
+            Configuration.OutputFile = tempOutputFile;
+            var json = SimpleJsonSerializer.Serialize(Configuration);
+            QpdfAPI.ExecuteJob(json);
+            
+            using var fileStream = File.OpenRead(tempOutputFile);
+            fileStream.CopyTo(stream);
+        }
+        finally
+        {
+            try
+            {
+                if (File.Exists(tempOutputFile))
+                    File.Delete(tempOutputFile);
+            }
+            catch
+            {
+                // Ignore cleanup errors
+            }
+            
+            CleanupTemporaryFiles();
+        }
     }
 }