diff --git a/src/foundation/src/PDFsharp/src/PdfSharp/Pdf.IO/PdfReader.cs b/src/foundation/src/PDFsharp/src/PdfSharp/Pdf.IO/PdfReader.cs index e872c4c4..68dcfd8c 100644 --- a/src/foundation/src/PDFsharp/src/PdfSharp/Pdf.IO/PdfReader.cs +++ b/src/foundation/src/PDFsharp/src/PdfSharp/Pdf.IO/PdfReader.cs @@ -305,6 +305,14 @@ PdfDocument OpenFromStream(Stream stream, string? password, PdfDocumentOpenMode throw new PdfReaderException("PdfReader needs a stream that supports the Length property.", ex); } + if (openMode == PdfDocumentOpenMode.ModifyIncremental) + { + // An incremental update writes the bytes of the original file unchanged before it appends + // the modified objects. The stream is not necessarily available anymore when the document + // is saved, therefore the bytes are kept in memory. + _document.OriginalBytes = ReadAllBytes(stream); + } + // Get file version. byte[] header = new byte[1024]; stream.Position = 0; @@ -440,7 +448,7 @@ PdfDocument OpenFromStream(Stream stream, string? password, PdfDocumentOpenMode reachables = document.xrefTable.AllXRefs; document.xrefTable.CheckConsistence(); #endif - if (openMode == PdfDocumentOpenMode.Modify) + if (openMode is PdfDocumentOpenMode.Modify or PdfDocumentOpenMode.ModifyIncremental) { // Create new or change existing document IDs. if (_document.Internals.SecondDocumentID == "") @@ -455,20 +463,42 @@ PdfDocument OpenFromStream(Stream stream, string? password, PdfDocumentOpenMode // Change modification date. _document.Info.ModificationDate = DateTimeOffset.Now; - // Remove all unreachable objects. - int removed = _document.IrefTable.Compact(); - if (removed != 0) + if (openMode == PdfDocumentOpenMode.ModifyIncremental) { - //Debug.WriteLine("Number of deleted unreachable objects: " + removed); - PdfSharpLogHost.PdfReadingLogger.LogInformation("Number of deleted unreachable objects: {Removed}", removed); + // An incremental update rewrites only the objects that were modified. The document + // information dictionary was just modified, so it must be written again. + _document.MarkAsModified(_document.Info); + } + else + { + // Remove all unreachable objects. + // Not for an incremental update: the objects of the original file are written unchanged + // and may be referenced by an earlier revision of the document. + int removed = _document.IrefTable.Compact(); + if (removed != 0) + { + //Debug.WriteLine("Number of deleted unreachable objects: " + removed); + PdfSharpLogHost.PdfReadingLogger.LogInformation("Number of deleted unreachable objects: {Removed}", removed); + } } // Force flattening of page tree. _document.Pages.FlattenPageTree(); _document.IrefTable.CheckConsistence(); - _document.IrefTable.Renumber(); - _document.IrefTable.CheckConsistence(); + if (openMode == PdfDocumentOpenMode.Modify) + { + // Renumbering the objects would invalidate the cross-reference table of the original + // file, which is kept unchanged by an incremental update. + _document.IrefTable.Renumber(); + _document.IrefTable.CheckConsistence(); + } + else + { + // Remember the objects of the original file. An incremental update writes all objects + // that are not contained here, because they were created after the document was read. + _document.OriginalObjectIDs = [.. _document.IrefTable.AllObjectIDs]; + } } else if (openMode == PdfDocumentOpenMode.Import) { @@ -496,6 +526,32 @@ PdfDocument OpenFromStream(Stream stream, string? password, PdfDocumentOpenMode return _document; } + /// + /// Reads the whole stream from its beginning and restores the original stream position. + /// + static byte[] ReadAllBytes(Stream stream) + { + var position = stream.Position; + try + { + stream.Position = 0; + var bytes = new byte[stream.Length]; + var offset = 0; + while (offset < bytes.Length) + { + var read = stream.Read(bytes, offset, bytes.Length - offset); + if (read <= 0) + throw new PdfReaderException("Unexpected end of the stream to be read."); + offset += read; + } + return bytes; + } + finally + { + stream.Position = position; + } + } + /// /// Ensures that all references in all objects refer to the actual object or to the null object (see ShouldUpdateReference method). /// diff --git a/src/foundation/src/PDFsharp/src/PdfSharp/Pdf.IO/enums/PdfDocumentOpenMode.cs b/src/foundation/src/PDFsharp/src/PdfSharp/Pdf.IO/enums/PdfDocumentOpenMode.cs index d4149e57..6a35aac1 100644 --- a/src/foundation/src/PDFsharp/src/PdfSharp/Pdf.IO/enums/PdfDocumentOpenMode.cs +++ b/src/foundation/src/PDFsharp/src/PdfSharp/Pdf.IO/enums/PdfDocumentOpenMode.cs @@ -35,5 +35,17 @@ public enum PdfDocumentOpenMode /// [Obsolete("InformationOnly is not implemented, use Import instead.")] InformationOnly, + + // Note: New members must be appended here to keep the numeric values of the existing members stable. + + /// + /// Like , but the object numbering of the original file is preserved: unreachable + /// objects are not removed and the cross-reference table is not renumbered. This is a prerequisite for + /// an append-only incremental update, where the bytes of the original file are written unchanged and + /// every object must keep the object number it has in that file. + /// A document opened in this mode can only be saved with + /// and its overloads. + /// + ModifyIncremental, } } diff --git a/src/foundation/src/PDFsharp/src/PdfSharp/Pdf.Signatures/DigitalSignatureHandler.cs b/src/foundation/src/PDFsharp/src/PdfSharp/Pdf.Signatures/DigitalSignatureHandler.cs index a6d00f3c..601f2dd6 100644 --- a/src/foundation/src/PDFsharp/src/PdfSharp/Pdf.Signatures/DigitalSignatureHandler.cs +++ b/src/foundation/src/PDFsharp/src/PdfSharp/Pdf.Signatures/DigitalSignatureHandler.cs @@ -198,6 +198,16 @@ internal async Task AddSignatureComponentsAsync() // #US321 TODO Use appropriate } acroForm.Fields.Elements.Add(signatureField); + + // The page, the array of annotations (which may be an indirect object of its own), the + // interactive form and the catalog are objects of the original file that are modified here. + // An incremental update must write them again. + Document.MarkAsModified(page); + if (annotations != null) + Document.MarkAsModified(annotations); + Document.MarkAsModified(acroForm); + Document.MarkAsModified(acroForm.Fields); + Document.MarkAsModified(catalog); } PdfFormSignatureField GetSignatureField(PdfSignature signatureDic) // #US321 TODO Use appropriate classes. diff --git a/src/foundation/src/PDFsharp/src/PdfSharp/Pdf/PdfDocument.Incremental.cs b/src/foundation/src/PDFsharp/src/PdfSharp/Pdf/PdfDocument.Incremental.cs new file mode 100644 index 00000000..73835944 --- /dev/null +++ b/src/foundation/src/PDFsharp/src/PdfSharp/Pdf/PdfDocument.Incremental.cs @@ -0,0 +1,389 @@ +// PDFsharp - A .NET library for processing PDF +// See the LICENSE file in the solution root for more information. + +using PdfSharp.Internal; +using PdfSharp.Pdf.Advanced; +using PdfSharp.Pdf.IO; + +namespace PdfSharp.Pdf +{ + partial class PdfDocument + { + /// + /// Gets or sets the bytes of the file the document was read from. + /// Only set if the document was opened with , + /// because only an incremental update needs them. + /// + /// + /// Known limitation: the whole file is kept in memory, because the stream it was read from is not + /// necessarily open anymore when the document is saved. Opening a large file with + /// therefore needs the size of the file in + /// addition to the memory the document needs anyway. + /// + internal byte[]? OriginalBytes { get; set; } + + /// + /// Gets or sets the identifiers of all objects the document was read from the file with. + /// Only set if the document was opened with . + /// Every object not contained here is a new object and therefore part of an incremental update. + /// + internal HashSet? OriginalObjectIDs { get; set; } + + /// + /// Declares an object as modified, so that it is written again by the next incremental update. + /// + /// + /// An incremental update writes only new and modified objects. PDFsharp cannot detect the modification + /// of an object, therefore every modified object of the original file must be declared with this function. + /// New objects are detected automatically and need not be declared. + /// Calling this function has no effect if the document was not opened with + /// . + /// + /// The modified object. Direct objects are ignored, because they are written + /// as part of the indirect object that contains them. Declare that object instead. + public void MarkAsModified(PdfObject obj) + { + if (obj is null) + throw new ArgumentNullException(nameof(obj)); + + if (OpenMode != PdfDocumentOpenMode.ModifyIncremental) + return; + + if (obj.Reference is null) + return; + + _modifiedObjectIDs.Add(obj.Reference.ObjectID); + } + + /// + /// Saves the document as an incremental update to the specified path. + /// If a file already exists, it will be overwritten. + /// + /// The path of the file to create. + public void SaveIncremental(string path) + { + // Safely call the async version on the current thread. + SaveIncrementalAsync(path).GetAwaiter().GetResult(); + } + + /// + /// Saves the document async as an incremental update to the specified path. + /// If a file already exists, it will be overwritten. + /// The async version of save is useful if you want to create a signed PDF file with a time stamp. + /// A time stamp server should be accessed asynchronously, and therefore we introduced this function. + /// + /// The path of the file to create. + public async Task SaveIncrementalAsync(string path) + { + EnsureCanSaveIncremental(); + + // We need ReadWrite when adding a signature. Write is sufficient if not adding a signature. + var fileAccess = DigitalSignatureHandler == null ? FileAccess.Write : FileAccess.ReadWrite; + + // ReSharper disable once UseAwaitUsing because we need no DisposeAsync for a simple FileStream. + using var stream = new FileStream(path, FileMode.Create, fileAccess, FileShare.None); + await SaveIncrementalAsync(stream).ConfigureAwait(false); + } + + /// + /// Saves the document as an incremental update to the specified stream. + /// + /// The stream the document is written to. It must be empty and positioned at its + /// beginning. If the document is signed, the stream must also be readable and seekable. + /// If set to true the stream is closed after saving. + public void SaveIncremental(Stream stream, bool closeStream = false) + { + // Safely call the async version on the current thread. + SaveIncrementalAsync(stream, closeStream).GetAwaiter().GetResult(); + } + + /// + /// Saves the document async as an incremental update to the specified stream. + /// The async version of save is useful if you want to create a signed PDF file with a time stamp. + /// A time stamp server should be accessed asynchronously, and therefore we introduced this function. + /// + /// + /// An incremental update writes the bytes of the original file unchanged, followed by the new and the + /// modified objects and a cross-reference section that is chained to the cross-reference section of the + /// original file by its /Prev entry. Because no byte of the original file is touched, a digital signature + /// the original file may contain stays valid and further signatures can be added one by one. + /// The document must be opened with , because the + /// object numbers of the original file must be preserved. + /// Every modified object of the original file must be declared with . + /// Known limitation: the bytes of the original file are kept in memory from opening the document + /// until saving it. + /// + /// The stream the document is written to. It must be empty and positioned at its + /// beginning. If the document is signed, the stream must also be readable and seekable. + /// If set to true the stream is closed after saving. + public async Task SaveIncrementalAsync(Stream stream, bool closeStream = false) + { + EnsureCanSaveIncremental(); + + if (!stream.CanWrite) + throw new InvalidOperationException(PsMsgs.StreamMustBeWritable); + + // The positions of the cross-reference section and of the signature refer to the beginning of + // the stream, therefore the document must be written to an empty stream. + if (stream.CanSeek && stream.Position != 0) + { + throw new InvalidOperationException( + "An incremental update must be written to an empty stream positioned at its beginning."); + } + + var originalBytes = OriginalBytes ?? throw new InvalidOperationException( + "The bytes of the original file are not available. " + + "Open the document with PdfDocumentOpenMode.ModifyIncremental to save it as an incremental update."); + + var previousStartxref = FindLastStartxref(originalBytes); + + PdfWriter? writer = null; + try + { + writer = new PdfWriter(stream, this, null); + + // Prepare for signing. New objects created here are part of the incremental update. + if (DigitalSignatureHandler != null) + await DigitalSignatureHandler.AddSignatureComponentsAsync().ConfigureAwait(false); + + PrepareForSaveIncremental(); + + // The set is never empty: reading the document changed its modification date. + var changedReferences = GetChangedReferences(); + + // 1. Write the original file unchanged. + stream.Write(originalBytes, 0, originalBytes.Length); + var lastByte = originalBytes[originalBytes.Length - 1]; + if (lastByte != '\n' && lastByte != '\r') + writer.WriteRaw("\n"); + + // 2. Write the new and the modified objects. + foreach (var iref in changedReferences) + { + iref.Position = writer.Position; + iref.Value.WriteObject(writer); + } + + // 3. Write the cross-reference section of this update. + var startxref = writer.Position; + WriteIncrementalXRefSection(writer, changedReferences); + + // 4. Write the trailer. It keeps /ID, /Root and /Info of the original file and refers to the + // cross-reference section of the previous revision. + Trailer.Elements.SetInteger(PdfTrailer.Keys.Size, IrefTable.MaxObjectNumber + 1); + Trailer.Elements[PdfTrailer.Keys.Prev] = new PdfLongInteger(previousStartxref); + writer.WriteRaw("trailer\n"); + Trailer.WriteObject(writer); + + writer.WriteRaw("startxref\n"); + writer.WriteRaw(startxref.ToString(CultureInfo.InvariantCulture)); + writer.WriteRaw("\n%%EOF\n"); + + // 5. Compute /ByteRange and /Contents of the signature. The ranges cover the whole file except + // the hole of the /Contents entry, therefore the original revision is signed too. + if (DigitalSignatureHandler != null) + await DigitalSignatureHandler.ComputeSignatureAndRange(writer).ConfigureAwait(false); + } + finally + { + State |= DocumentState.Saved; + + if (stream != null!) + { + stream.Flush(); + if (!closeStream && stream is { CanRead: true, CanSeek: true }) + stream.Position = 0; // Reset the stream position if the stream is kept open. + } + + writer?.Close(closeStream); + } + } + + /// + /// Checks whether this document can be saved as an incremental update. + /// + void EnsureCanSaveIncremental() + { + EnsureNotYetSaved(); + + if (OpenMode != PdfDocumentOpenMode.ModifyIncremental) + { + throw new InvalidOperationException( + "Only a document opened with PdfDocumentOpenMode.ModifyIncremental can be saved as an " + + "incremental update, because the object numbers of the original file must be preserved."); + } + + // An incremental update appends unencrypted objects to the original file. Encrypting them would + // require the encryption key of the original file, which PDFsharp resets when reading the document. + if (Trailer.Elements.ContainsKey(PdfTrailer.Keys.Encrypt)) + { + throw new NotSupportedException( + "An encrypted document cannot be saved as an incremental update."); + } + + if (SecuritySettings.EffectiveSecurityHandler != null) + { + throw new NotSupportedException( + "A document cannot be encrypted when it is saved as an incremental update."); + } + + // The cross-reference section written by an incremental update is a cross-reference table. + // Chaining it to a cross-reference stream of the original file is not valid PDF. + if (Trailer is PdfCrossReferenceStream) + { + throw new NotSupportedException( + "A document with a cross-reference stream cannot be saved as an incremental update, " + + "because PDFsharp writes a cross-reference table. Save the document with Save instead."); + } + } + + /// + /// Dispatches PrepareForSave to the objects that need it. + /// In contrast to the version used by Save, neither unreachable objects are removed nor are the objects + /// renumbered, because an incremental update must preserve the object numbers of the original file. + /// + void PrepareForSaveIncremental() + { + // Keep the original producer. This is “PDF created by” in Adobe Reader. + if (Info.Producer.Length == 0) + Info.Elements.SetString(PdfDocumentInformation.Keys.Producer, DefaultProducer); + + // Prepare used fonts. + _fontTable?.PrepareForSave(); + + // Let catalog do the rest. It may modify itself, e.g. by adding metadata, so it is written again. + MarkAsModified(Catalog); + Catalog.PrepareForSave(); + } + + /// + /// Gets the references of all objects that must be written by the incremental update, in ascending + /// order by their object number. These are all objects that are not contained in the original file + /// plus all objects declared as modified. + /// + List GetChangedReferences() + { + var originalObjectIDs = OriginalObjectIDs; + var changedReferences = new List(); + + // Removing an object would require a free entry in the cross-reference section of the update. + // PDFsharp does not write free entries, so the object would still be reachable. + if (originalObjectIDs != null) + { + foreach (var objectID in originalObjectIDs) + { + if (!IrefTable.Contains(objectID)) + { + throw new NotSupportedException( + Invariant($"Object {objectID} was removed from the document. Removing an object is ") + + "not supported by an incremental update."); + } + } + } + + // AllReferences is sorted by object identifier, so the result is sorted too. + foreach (var iref in IrefTable.AllReferences) + { + if (iref.ObjectNumber <= 0) + continue; + + var isNewObject = originalObjectIDs is null || !originalObjectIDs.Contains(iref.ObjectID); + if (isNewObject || _modifiedObjectIDs.Contains(iref.ObjectID)) + changedReferences.Add(iref); + } + return changedReferences; + } + + /// + /// Writes the cross-reference table of an incremental update. It contains only the objects written by + /// this update, grouped into subsections of consecutive object numbers. + /// + static void WriteIncrementalXRefSection(PdfWriter writer, List changedReferences) + { + writer.WriteRaw("xref\n"); + + int index = 0; + int count = changedReferences.Count; + while (index < count) + { + // Find the end of the subsection of consecutive object numbers. + int endOfSubsection = index + 1; + while (endOfSubsection < count && + changedReferences[endOfSubsection].ObjectNumber == changedReferences[endOfSubsection - 1].ObjectNumber + 1) + { + endOfSubsection++; + } + + writer.WriteRaw(Invariant($"{changedReferences[index].ObjectNumber} {endOfSubsection - index}\n")); + for (int idx = index; idx < endOfSubsection; idx++) + { + var iref = changedReferences[idx]; + + // Acrobat is very pedantic; it must be exactly 20 bytes per line. + writer.WriteRaw(Invariant($"{iref.Position:0000000000} {iref.GenerationNumber:00000} n \n")); + } + index = endOfSubsection; + } + } + + /// + /// Gets the position of the cross-reference section of the last revision of the original file, + /// i.e. the value of its last startxref entry. + /// + static SizeType FindLastStartxref(byte[] originalBytes) + { + // "startxref" is followed by the position of the cross-reference section and by "%%EOF". + var position = LastIndexOf(originalBytes, "startxref"); + if (position < 0) + { + throw new InvalidOperationException( + "The original file contains no startxref entry and therefore cannot be updated incrementally."); + } + + int index = position + "startxref".Length; + while (index < originalBytes.Length && Lexer.IsWhiteSpace((char)originalBytes[index])) + index++; + + SizeType startxref = 0; + var digits = 0; + while (index < originalBytes.Length && originalBytes[index] is >= (byte)'0' and <= (byte)'9') + { + startxref = startxref * 10 + (originalBytes[index] - '0'); + digits++; + index++; + } + + if (digits == 0 || startxref >= originalBytes.Length) + { + throw new InvalidOperationException( + "The startxref entry of the original file does not contain a valid position."); + } + return startxref; + } + + /// + /// Gets the position of the last occurrence of the specified ASCII text in the specified bytes, + /// or -1 if the text does not occur. + /// + static int LastIndexOf(byte[] bytes, string text) + { + for (int start = bytes.Length - text.Length; start >= 0; start--) + { + var found = true; + for (int idx = 0; idx < text.Length; idx++) + { + if (bytes[start + idx] != (byte)text[idx]) + { + found = false; + break; + } + } + if (found) + return start; + } + return -1; + } + + readonly HashSet _modifiedObjectIDs = []; + } +} diff --git a/src/foundation/src/PDFsharp/src/PdfSharp/Pdf/PdfDocument.cs b/src/foundation/src/PDFsharp/src/PdfSharp/Pdf/PdfDocument.cs index 777f441f..b29899c7 100644 --- a/src/foundation/src/PDFsharp/src/PdfSharp/Pdf/PdfDocument.cs +++ b/src/foundation/src/PDFsharp/src/PdfSharp/Pdf/PdfDocument.cs @@ -33,7 +33,7 @@ namespace PdfSharp.Pdf /// Represents a PDF document. /// [DebuggerDisplay("(Name={" + nameof(Name) + "})")] // A unique name makes debugging easier. - public sealed class PdfDocument : IDisposable + public sealed partial class PdfDocument : IDisposable { /// /// Creates a new PDF document in memory. @@ -195,7 +195,7 @@ static string NewName() static int _nameCount; - internal bool CanModify => OpenMode == PdfDocumentOpenMode.Modify; + internal bool CanModify => OpenMode is PdfDocumentOpenMode.Modify or PdfDocumentOpenMode.ModifyIncremental; /// /// Gets or sets a value indicating whether to save a document even if it is imported. @@ -669,7 +669,7 @@ internal DocumentHandle Handle /// /// Returns a value indicating whether the document is read only or can be modified. /// - public bool IsReadOnly => (OpenMode != PdfDocumentOpenMode.Modify); + public bool IsReadOnly => !CanModify; /// /// Gets information about the document. diff --git a/src/foundation/src/PDFsharp/tests/PdfSharp.Tests/IO/IncrementalUpdateTests.cs b/src/foundation/src/PDFsharp/tests/PdfSharp.Tests/IO/IncrementalUpdateTests.cs new file mode 100644 index 00000000..78feaafd --- /dev/null +++ b/src/foundation/src/PDFsharp/tests/PdfSharp.Tests/IO/IncrementalUpdateTests.cs @@ -0,0 +1,404 @@ +// PDFsharp - A .NET library for processing PDF +// See the LICENSE file in the solution root for more information. + +using System.Globalization; +#if WPF +using System.IO; +#endif +using FluentAssertions; +using PdfSharp.Diagnostics; +using PdfSharp.Drawing; +using PdfSharp.Pdf; +using PdfSharp.Pdf.IO; +using PdfSharp.Pdf.Annotations; +using PdfSharp.Pdf.Forms; +using PdfSharp.Pdf.Signatures; +#if CORE +using PdfSharp.Fonts; +using PdfSharp.Quality; +#endif +using Xunit; + +namespace PdfSharp.Tests.IO +{ + [Collection("PDFsharp")] + public class IncrementalUpdateTests : IDisposable + { + public IncrementalUpdateTests() + { + PdfSharpCore.ResetAll(); +#if CORE + GlobalFontSettings.FontResolver = new UnitTestFontResolver(); +#endif + } + + public void Dispose() + { + PdfSharpCore.ResetAll(); + } + + [Fact] + public void Save_incremental_writes_the_original_file_unchanged() + { + var originalBytes = CreateDocument(2); + + using var document = PdfReader.Open(new MemoryStream(originalBytes), PdfDocumentOpenMode.ModifyIncremental); + document.AddPage(); + document.MarkAsModified(document.Pages); + + var updatedBytes = SaveIncremental(document); + + updatedBytes.Length.Should().BeGreaterThan(originalBytes.Length); + updatedBytes.Take(originalBytes.Length).Should().Equal(originalBytes, + "an incremental update must not touch a single byte of the original file"); + + using var updatedDocument = PdfReader.Open(new MemoryStream(updatedBytes), PdfDocumentOpenMode.Import); + updatedDocument.PageCount.Should().Be(3); + } + + [Fact] + public void Save_incremental_chains_the_cross_reference_sections() + { + var originalBytes = CreateDocument(1); + var originalStartxref = LastStartxrefOf(originalBytes); + + using var document = PdfReader.Open(new MemoryStream(originalBytes), PdfDocumentOpenMode.ModifyIncremental); + document.Info.Title = "Incremental update"; + document.MarkAsModified(document.Info); + + var updatedBytes = SaveIncremental(document); + var updatedText = TextOf(updatedBytes); + + // The trailer of the update refers to the cross-reference section of the original file… + updatedText.Should().Contain($"/Prev {originalStartxref}"); + // … and the last startxref refers to the cross-reference section of the update. + var updatedStartxref = LastStartxrefOf(updatedBytes); + updatedStartxref.Should().BeGreaterThan(originalBytes.Length); + TextOf(updatedBytes).Substring((int)updatedStartxref, 4).Should().Be("xref"); + + using var updatedDocument = PdfReader.Open(new MemoryStream(updatedBytes), PdfDocumentOpenMode.Import); + updatedDocument.Info.Title.Should().Be("Incremental update"); + } + + [Fact] + public void Open_mode_ModifyIncremental_preserves_the_object_numbers() + { + // Create a document with an object that Modify would remove and objects that Modify would renumber. + var originalBytes = CreateDocument(3); + var originalRoot = RootObjectNumberOf(originalBytes); + + using var incremental = PdfReader.Open(new MemoryStream(originalBytes), PdfDocumentOpenMode.ModifyIncremental); + incremental.Catalog.RequiredReference.ObjectNumber.Should().Be(originalRoot, + "ModifyIncremental must not renumber the objects of the original file"); + incremental.IsReadOnly.Should().BeFalse(); + + // The objects written by an incremental update get object numbers behind the ones of the original file. + var maxObjectNumber = incremental.Internals.GetAllObjects().Max(obj => obj.ObjectNumber); + incremental.AddPage(); + incremental.Internals.GetAllObjects().Max(obj => obj.ObjectNumber).Should().BeGreaterThan(maxObjectNumber); + } + + [Fact] + public void Save_incremental_requires_open_mode_ModifyIncremental() + { + var originalBytes = CreateDocument(1); + + using var document = PdfReader.Open(new MemoryStream(originalBytes), PdfDocumentOpenMode.Modify); + document.Info.Title = "No incremental update"; + + Action save = () => document.SaveIncremental(new MemoryStream()); + save.Should().Throw().WithMessage("*ModifyIncremental*"); + } + + [Fact] + public void Save_incremental_rejects_an_encrypted_document() + { + const string password = "Seecrit1243"; + using var stream = new MemoryStream(); + using (var newDocument = new PdfDocument()) + { + newDocument.AddPage(); + newDocument.SecuritySettings.UserPassword = password; + newDocument.Save(stream, false); + } + + using var document = PdfReader.Open(new MemoryStream(stream.ToArray()), password, + PdfDocumentOpenMode.ModifyIncremental); + document.Info.Title = "Encrypted"; + document.MarkAsModified(document.Info); + + Action save = () => document.SaveIncremental(new MemoryStream()); + save.Should().Throw().WithMessage("An encrypted document*"); + } + + [Fact] + public void Save_incremental_rejects_the_removal_of_an_object() + { + var originalBytes = CreateDocument(2); + + using var document = PdfReader.Open(new MemoryStream(originalBytes), PdfDocumentOpenMode.ModifyIncremental); + document.Internals.RemoveObject(document.Pages[1]); + + // Removing an object would require a free entry in the cross-reference section of the update. + Action save = () => document.SaveIncremental(new MemoryStream()); + save.Should().Throw().WithMessage("*Removing an object*"); + } + + [Fact] + public void Save_incremental_rejects_a_document_with_a_cross_reference_stream() + { + var originalBytes = CreateDocumentWithCrossReferenceStream(); + + using var document = PdfReader.Open(new MemoryStream(originalBytes), PdfDocumentOpenMode.ModifyIncremental); + + // PDFsharp writes a cross-reference table, which must not be chained to a cross-reference stream. + Action save = () => document.SaveIncremental(new MemoryStream()); + save.Should().Throw().WithMessage("*cross-reference stream*"); + } + + [Fact] + public void Save_incremental_writes_the_modifications_declared_with_MarkAsModified() + { + var originalBytes = CreateDocument(1); + var originalWidth = WidthOfFirstPage(originalBytes); + + // PDFsharp cannot detect the modification of an object, so a modification that is not declared + // is not written by an incremental update. + var notDeclaredBytes = ResizeFirstPage(originalBytes, declareAsModified: false); + WidthOfFirstPage(notDeclaredBytes).Should().Be(originalWidth); + + var declaredBytes = ResizeFirstPage(originalBytes, declareAsModified: true); + WidthOfFirstPage(declaredBytes).Should().Be(originalWidth + 100); + + static byte[] ResizeFirstPage(byte[] pdfBytes, bool declareAsModified) + { + using var document = PdfReader.Open(new MemoryStream(pdfBytes), PdfDocumentOpenMode.ModifyIncremental); + var page = document.Pages[0]; + page.Width = XUnit.FromPoint(page.Width.Point + 100); + if (declareAsModified) + document.MarkAsModified(page); + return SaveIncremental(document); + } + + static double WidthOfFirstPage(byte[] pdfBytes) + { + using var document = PdfReader.Open(new MemoryStream(pdfBytes), PdfDocumentOpenMode.Import); + return document.Pages[0].Width.Point; + } + } + + [Fact] + public void Save_incremental_adds_one_signature_after_the_other() + { + var originalBytes = CreateDocument(1); + + var onceSignedBytes = Sign(originalBytes); + var twiceSignedBytes = Sign(onceSignedBytes); + + // Both updates keep all previously written bytes, so the first signature stays valid. + onceSignedBytes.Take(originalBytes.Length).Should().Equal(originalBytes); + twiceSignedBytes.Take(onceSignedBytes.Length).Should().Equal(onceSignedBytes); + + // Both signatures are in the document, the second one did not replace the first one. + FieldNamesOf(twiceSignedBytes).Should().HaveCount(2); + + // The second signature covers the whole file except the hole of its /Contents entry. + var byteRanges = ByteRangesOf(twiceSignedBytes); + byteRanges.Count.Should().Be(2); + var lastByteRange = byteRanges[byteRanges.Count - 1]; + lastByteRange[0].Should().Be(0); + (lastByteRange[2] + lastByteRange[3]).Should().Be(twiceSignedBytes.Length); + } + + /// + /// Creates a PDF file with the specified number of pages. + /// + static byte[] CreateDocument(int pageCount) + { + using var document = new PdfDocument(); + for (int idx = 0; idx < pageCount; idx++) + document.AddPage(); + document.Info.Author = "PDFsharp"; + + using var stream = new MemoryStream(); + document.Save(stream, false); + return stream.ToArray(); + } + + /// + /// Creates a PDF file with one page that uses a cross-reference stream instead of a + /// cross-reference table. PDFsharp can read such a file, but cannot write one. + /// + static byte[] CreateDocumentWithCrossReferenceStream() + { + var stream = new MemoryStream(); + var positions = new int[5]; + + Write("%PDF-1.5\n%ÐÔÅØ\n"); + WriteObject(1, "<< /Type /Catalog /Pages 2 0 R >>"); + WriteObject(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"); + WriteObject(3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] >>"); + + // The cross-reference stream itself is object 4. Its entries are 'type offset generation', + // the field widths are defined by /W. + positions[4] = (int)stream.Length; + var entries = new byte[5 * 7]; + SetEntry(0, 0, 0, 65535); + for (int objectNumber = 1; objectNumber <= 4; objectNumber++) + SetEntry(objectNumber, 1, positions[objectNumber], 0); + + Write(Invariant($"4 0 obj\n<< /Type /XRef /Size 5 /W [1 4 2] /Root 1 0 R /Length {entries.Length} >>\nstream\n")); + stream.Write(entries, 0, entries.Length); + Write("\nendstream\nendobj\n"); + Write(Invariant($"startxref\n{positions[4]}\n%%EOF\n")); + + return stream.ToArray(); + + void Write(string text) + { + foreach (var ch in text) + stream.WriteByte((byte)ch); + } + + void WriteObject(int objectNumber, string value) + { + positions[objectNumber] = (int)stream.Length; + Write(Invariant($"{objectNumber} 0 obj\n{value}\nendobj\n")); + } + + void SetEntry(int index, byte type, int offset, int generation) + { + var start = index * 7; + entries[start] = type; + entries[start + 1] = (byte)(offset >> 24); + entries[start + 2] = (byte)(offset >> 16); + entries[start + 3] = (byte)(offset >> 8); + entries[start + 4] = (byte)offset; + entries[start + 5] = (byte)(generation >> 8); + entries[start + 6] = (byte)generation; + } + } + + /// + /// Adds a digital signature to the specified PDF file by an incremental update. + /// + static byte[] Sign(byte[] pdfBytes) + { + using var document = PdfReader.Open(new MemoryStream(pdfBytes), PdfDocumentOpenMode.ModifyIncremental); + var options = new DigitalSignatureOptions + { + ContactInfo = "John Doe", + Location = "Seattle", + Reason = "License Agreement", + Rectangle = new XRect(36, 36, 200, 50), + AppearanceHandler = new EmptyAppearanceHandler() + }; + _ = DigitalSignatureHandler.ForDocument(document, new TestSigner(), options); + + return SaveIncremental(document); + } + + static byte[] SaveIncremental(PdfDocument document) + { + using var stream = new MemoryStream(); + document.SaveIncremental(stream); + return stream.ToArray(); + } + + static string TextOf(byte[] pdfBytes) + { + var chars = new char[pdfBytes.Length]; + for (int idx = 0; idx < pdfBytes.Length; idx++) + chars[idx] = (char)pdfBytes[idx]; + return new String(chars); + } + + static long LastStartxrefOf(byte[] pdfBytes) + { + var text = TextOf(pdfBytes); + var index = text.LastIndexOf("startxref", StringComparison.Ordinal); + index.Should().BeGreaterThan(0); + return Int64.Parse(text.Substring(index + "startxref".Length).Trim().Split('\n')[0].Trim(), + CultureInfo.InvariantCulture); + } + + static int RootObjectNumberOf(byte[] pdfBytes) + { + var text = TextOf(pdfBytes); + var index = text.LastIndexOf("/Root", StringComparison.Ordinal); + index.Should().BeGreaterThan(0); + var value = text.Substring(index + "/Root".Length).TrimStart(); + return Int32.Parse(value.Substring(0, value.IndexOf(' ')), CultureInfo.InvariantCulture); + } + + /// + /// Gets the values of all /ByteRange entries of the specified PDF file in the order of their occurrence. + /// + static List ByteRangesOf(byte[] pdfBytes) + { + var text = TextOf(pdfBytes); + var byteRanges = new List(); + var index = 0; + while ((index = text.IndexOf("/ByteRange", index, StringComparison.Ordinal)) > 0) + { + var start = text.IndexOf('[', index); + var end = text.IndexOf(']', start); + byteRanges.Add(text.Substring(start + 1, end - start - 1) + .Split([' '], StringSplitOptions.RemoveEmptyEntries) + .Select(value => Int64.Parse(value, CultureInfo.InvariantCulture)) + .ToArray()); + index = end; + } + return byteRanges; + } + + /// + /// Gets the partial names of the fields at the root of the interactive form. + /// + static List FieldNamesOf(byte[] pdfBytes) + { + using var document = PdfReader.Open(new MemoryStream(pdfBytes), PdfDocumentOpenMode.Import); + var fields = document.Catalog.GetAcroForm()?.Elements.GetArray(PdfForm.Keys.Fields); + fields.Should().NotBeNull(); + + var names = new List(); + for (int idx = 0; idx < fields!.Elements.Count; idx++) + names.Add(fields.Elements.GetDictionary(idx)!.Elements.GetString(PdfFormField.Keys.T)); + return names; + } + + /// + /// A signer that creates a deterministic dummy signature, so that no certificate is needed. + /// + class TestSigner : IDigitalSigner + { + public string CertificateName => "PDFsharp unit test"; + + public Task GetSignatureSizeAsync() => Task.FromResult(SignatureSize); + + public Task GetSignatureAsync(Stream stream) + { + // Read the stream to ensure the ranges to be signed are readable. + var buffer = new byte[4096]; + while (stream.Read(buffer, 0, buffer.Length) > 0) + { } + + var signature = new byte[SignatureSize]; + for (int idx = 0; idx < signature.Length; idx++) + signature[idx] = (byte)idx; + return Task.FromResult(signature); + } + + const int SignatureSize = 512; + } + + /// + /// An appearance handler that draws nothing, so that no font is needed. + /// + class EmptyAppearanceHandler : IAnnotationAppearanceHandler + { + public void DrawAppearance(XGraphics gfx, XRect rect) + { } + } + } +}