From ba7d3c10d18d0168490f661e378afa1e0c863ee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=9F=D0=B0=D0=BD=D0=B0=D1=81=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Tue, 11 Aug 2026 14:28:18 +0300 Subject: [PATCH] Add append-only incremental update New open mode ModifyIncremental and new function PdfDocument.SaveIncremental. An incremental update writes the bytes of the original file unchanged, followed by the new and the modified objects and a cross-reference section 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 of the original file stays valid and further signatures can be added one by one, which is not possible with Save. Opening a document with Modify runs IrefTable.Compact() and Renumber(). Both are fatal here, because the objects of the original file are written unchanged and are referenced by their original object numbers. ModifyIncremental behaves like Modify but skips both steps, keeps the bytes and the object identifiers of the original file, and is treated as writable by CanModify and IsReadOnly. PDFsharp cannot detect the modification of an object, therefore modified objects must be declared with the new function PdfDocument.MarkAsModified. DigitalSignatureHandler declares the objects it modifies. Encrypted documents, documents with a cross-reference stream and the removal of an object are rejected with a dedicated exception, because appending unencrypted objects, chaining a cross-reference table to a cross-reference stream respectively omitting the free entry of a removed object is not valid. --- .../PDFsharp/src/PdfSharp/Pdf.IO/PdfReader.cs | 72 +++- .../Pdf.IO/enums/PdfDocumentOpenMode.cs | 12 + .../Pdf.Signatures/DigitalSignatureHandler.cs | 10 + .../PdfSharp/Pdf/PdfDocument.Incremental.cs | 389 +++++++++++++++++ .../PDFsharp/src/PdfSharp/Pdf/PdfDocument.cs | 6 +- .../IO/IncrementalUpdateTests.cs | 404 ++++++++++++++++++ 6 files changed, 882 insertions(+), 11 deletions(-) create mode 100644 src/foundation/src/PDFsharp/src/PdfSharp/Pdf/PdfDocument.Incremental.cs create mode 100644 src/foundation/src/PDFsharp/tests/PdfSharp.Tests/IO/IncrementalUpdateTests.cs 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) + { } + } + } +}