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)
+ { }
+ }
+ }
+}