diff --git a/src/Filee.Engines/Pdf/PdfRepair.cs b/src/Filee.Engines/Pdf/PdfRepair.cs
new file mode 100644
index 000000000..e5be83d98
--- /dev/null
+++ b/src/Filee.Engines/Pdf/PdfRepair.cs
@@ -0,0 +1,254 @@
+// Mends the damage that some PDF writers (a few Chinese OA / ERP "print to PDF" exporters, issue #1175) leave behind and
+// that PDFsharp refuses to read, so merge, split and compress failed on those files: an object closed with one ">>" too
+// many (the bookmark entries in the file from #1175).
+//
+// The repair only runs after PDFsharp has failed on a file. It overwrites each surplus ">>" with two spaces, so every
+// byte keeps its place and the file's own xref table stays valid; nothing is rebuilt. It only looks at objects the xref
+// table lists (never at what a stream holds, such as an attached PDF), skips objects that have a stream (a "stream"
+// keyword outside strings, names and comments), and does not count ">>" inside strings. Files it cannot judge
+// (encrypted, or with an xref stream) are returned as null and fail as before. The file on disk is never touched.
+//
+// It works on the bytes (no text copy of the file), and each object is only searched up to the next listed object (and
+// never more than MaxObjectBytes), so a file that fails for another reason is judged in one pass over it, not one per
+// object. It can be cancelled.
+
+namespace Filee.Engines.Pdf;
+
+internal static class PdfRepair
+{
+ /// Above this size the repair is not tried (it holds the whole file in memory).
+ private const int MaxBytes = 128 * 1024 * 1024;
+
+ /// The most an object without a stream is searched for its "endobj".
+ private const int MaxObjectBytes = 4 * 1024 * 1024;
+
+ /// The most bytes "N G obj" may take at the start of an object.
+ private const int MaxHeaderBytes = 32;
+
+ /// Reads and repairs it; null when it is too big, unreadable, or out of reach of the repair.
+ public static byte[]? RepairFile(string path, CancellationToken cancellationToken = default)
+ {
+ byte[] pdf;
+ try
+ {
+ if (new FileInfo(path).Length is 0 or > MaxBytes)
+ return null;
+ pdf = File.ReadAllBytes(path);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ return Repair(pdf, cancellationToken);
+ }
+
+ /// A repaired copy of the PDF, or null when there is nothing to mend or the file is out of reach of this repair.
+ public static byte[]? Repair(byte[] pdf, CancellationToken cancellationToken = default)
+ {
+ if (pdf.Length is 0 or > MaxBytes)
+ return null;
+ if (pdf.AsSpan().IndexOf("/Encrypt"u8) >= 0)
+ return null;
+
+ // In file order, so that an object ends at the latest where the next listed one starts.
+ var offsets = ListedObjects(pdf).Order().ToArray();
+ byte[]? repaired = null;
+ for (var k = 0; k < offsets.Length; k++)
+ {
+ if ((k & 0x3FF) == 0)
+ cancellationToken.ThrowIfCancellationRequested();
+ var bound = k + 1 < offsets.Length ? offsets[k + 1] : pdf.Length;
+ foreach (var at in SurplusClose(pdf, offsets[k], bound))
+ {
+ repaired ??= (byte[])pdf.Clone();
+ repaired[at] = (byte)' ';
+ repaired[at + 1] = (byte)' ';
+ }
+ }
+ return repaired;
+ }
+
+ /// The offsets of the objects the file's classic xref table(s) list as in use; empty for an xref stream or no table.
+ private static HashSet ListedObjects(byte[] pdf)
+ {
+ var found = new HashSet();
+ var visited = new HashSet();
+ var at = LastStartXref(pdf);
+ while (at is { } offset && visited.Add(offset))
+ at = ReadTable(pdf, offset, found);
+ return found;
+ }
+
+ private static int? LastStartXref(byte[] pdf)
+ {
+ var keyword = pdf.AsSpan().LastIndexOf("startxref"u8);
+ if (keyword < 0)
+ return null;
+ var pos = keyword + "startxref".Length;
+ return TryNumber(pdf, ref pos, out var offset) ? offset : null;
+ }
+
+ /// Adds the listed objects of the table at to ; returns the /Prev table, if any.
+ private static int? ReadTable(byte[] pdf, int offset, HashSet found)
+ {
+ if (offset < 0 || offset >= pdf.Length)
+ return null;
+ var pos = offset;
+ SkipWhite(pdf, ref pos);
+ if (!pdf.AsSpan(pos).StartsWith("xref"u8))
+ return null; // an xref stream, or not a table at all
+
+ pos += 4;
+ while (true)
+ {
+ SkipWhite(pdf, ref pos);
+ if (pdf.AsSpan(pos).StartsWith("trailer"u8))
+ {
+ pos += 7;
+ break;
+ }
+ if (!TryNumber(pdf, ref pos, out _) || !TryNumber(pdf, ref pos, out var count))
+ return null;
+ for (var i = 0; i < count; i++)
+ {
+ if (!TryNumber(pdf, ref pos, out var entryOffset) || !TryNumber(pdf, ref pos, out _))
+ return null;
+ SkipWhite(pdf, ref pos);
+ if (pos >= pdf.Length)
+ return null;
+ if (pdf[pos++] == (byte)'n' && entryOffset > 0)
+ found.Add(entryOffset);
+ }
+ }
+
+ var trailer = pdf.AsSpan(pos);
+ var end = trailer.IndexOf("startxref"u8);
+ if (end >= 0)
+ trailer = trailer[..end];
+ var prev = trailer.IndexOf("/Prev"u8);
+ if (prev < 0)
+ return null;
+ var from = pos + prev + "/Prev".Length;
+ return TryNumber(pdf, ref from, out var previous) ? previous : null;
+ }
+
+ ///
+ /// The offsets of the ">>" an object has more than "<<": the last ones. The object is only searched up to
+ /// (where the next listed object starts). Objects with a stream are left alone, and so are
+ /// strings, hex strings and comments.
+ ///
+ private static List SurplusClose(byte[] pdf, int offset, int bound)
+ {
+ var none = new List();
+ if (offset < 0 || offset >= pdf.Length)
+ return none;
+
+ var header = pdf.AsSpan(offset, Math.Min(MaxHeaderBytes, pdf.Length - offset)).IndexOf("obj"u8);
+ if (header < 0)
+ return none;
+ for (var i = offset; i < offset + header; i++) // what sits at the offset must be "N G obj"
+ if (!char.IsAsciiDigit((char)pdf[i]) && pdf[i] != (byte)' ')
+ return none;
+
+ var start = offset + header + 3;
+ var stop = Math.Min(Math.Min(bound, pdf.Length), start + MaxObjectBytes);
+ if (stop <= start)
+ return none;
+ var length = pdf.AsSpan(start, stop - start).IndexOf("endobj"u8);
+ if (length < 0)
+ return none;
+ var end = start + length;
+
+ var open = 0;
+ var closes = new List();
+ for (var i = start; i < end; i++)
+ {
+ switch ((char)pdf[i])
+ {
+ case '(':
+ i = SkipLiteralString(pdf, i, end);
+ break;
+ case '%':
+ while (i + 1 < end && pdf[i + 1] is not ((byte)'\r' or (byte)'\n'))
+ i++;
+ break;
+ case '<' when i + 1 < end && pdf[i + 1] == (byte)'<':
+ open++;
+ i++;
+ break;
+ case '<': // a hex string
+ var greater = pdf.AsSpan(i, end - i).IndexOf((byte)'>');
+ if (greater < 0)
+ return none;
+ i += greater;
+ break;
+ case '>' when i + 1 < end && pdf[i + 1] == (byte)'>':
+ closes.Add(i);
+ i++;
+ break;
+ case 's' when IsStreamKeyword(pdf, i, start):
+ return none; // outside strings and comments, so a title like "Upstream plan" does not count
+ }
+ }
+ return closes.Count <= open ? none : closes.GetRange(open, closes.Count - open);
+ }
+
+ ///
+ /// True when "stream" at is the keyword: after a ">>" or white space. What follows it is not
+ /// looked at, because some files don't end the line right after it and the data must still not be counted.
+ ///
+ private static bool IsStreamKeyword(byte[] pdf, int at, int start)
+ {
+ if (!pdf.AsSpan(at).StartsWith("stream"u8) || at <= start)
+ return false;
+ return pdf[at - 1] is (byte)'>' or (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n' or (byte)'\f' or 0;
+ }
+
+ /// The index of the ")" that ends the literal string opened at (parentheses nest, "\" escapes).
+ private static int SkipLiteralString(byte[] pdf, int open, int end)
+ {
+ var depth = 1;
+ for (var i = open + 1; i < end; i++)
+ {
+ switch ((char)pdf[i])
+ {
+ case '\\':
+ i++;
+ break;
+ case '(':
+ depth++;
+ break;
+ case ')':
+ if (--depth == 0)
+ return i;
+ break;
+ }
+ }
+ return end;
+ }
+
+ private static void SkipWhite(byte[] pdf, ref int pos)
+ {
+ while (pos < pdf.Length && pdf[pos] is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n' or (byte)'\f' or 0)
+ pos++;
+ }
+
+ private static bool TryNumber(byte[] pdf, ref int pos, out int value)
+ {
+ SkipWhite(pdf, ref pos);
+ var from = pos;
+ while (pos < pdf.Length && char.IsAsciiDigit((char)pdf[pos]))
+ pos++;
+ value = 0;
+ if (pos == from || pos - from > 10)
+ return false;
+ foreach (var digit in pdf.AsSpan(from, pos - from))
+ {
+ var next = (long)value * 10 + (digit - '0');
+ if (next > int.MaxValue)
+ return false;
+ value = (int)next;
+ }
+ return true;
+ }
+}
diff --git a/src/Filee.Engines/Pdf/PdfSharpConverter.cs b/src/Filee.Engines/Pdf/PdfSharpConverter.cs
index 3ea39b560..57d8539af 100644
--- a/src/Filee.Engines/Pdf/PdfSharpConverter.cs
+++ b/src/Filee.Engines/Pdf/PdfSharpConverter.cs
@@ -2,6 +2,7 @@
// compression itself is in PdfCompressor, with Ghostscript as an optional second try) and removes their metadata.
// It also finishes every PDF a job writes with a watermark, page numbers or a password (PdfFinishing).
+using System.Runtime.ExceptionServices;
using Filee.Core.Conversion;
using Filee.Core.Presets;
using Filee.Engines.Infrastructure;
@@ -54,7 +55,7 @@ public Task MergeAsync(IReadOnlyList inputPaths, string outputPath, Canc
foreach (var input in inputPaths)
{
cancellationToken.ThrowIfCancellationRequested();
- using var source = Open(input, PdfDocumentOpenMode.Import);
+ using var source = Open(input, PdfDocumentOpenMode.Import, cancellationToken);
foreach (var page in source.Pages)
output.AddPage(page);
}
@@ -138,7 +139,7 @@ private static IReadOnlyList Sheets(ConversionStep step, IProgress
private static IReadOnlyList WithoutMetadata(ConversionStep step, IProgress? progress, CancellationToken ct)
{
- using var document = OpenForChanges(step.InputPath);
+ using var document = OpenForChanges(step.InputPath, ct);
ct.ThrowIfCancellationRequested();
StripMetadata(document);
progress?.Report(0.5);
@@ -171,7 +172,7 @@ internal static void StripMetadata(PdfDocument document)
/// PDF → PDF: split into single pages, extract a range, or rotate the complete document in place.
private static IReadOnlyList SplitOrExtract(ConversionStep step, IProgress? progress, CancellationToken ct)
{
- using var source = Open(step.InputPath, PdfDocumentOpenMode.Import);
+ using var source = Open(step.InputPath, PdfDocumentOpenMode.Import, ct);
var pages = PageRange.Parse(step.Preset.Pdf.PageRange, source.PageCount);
var written = new List();
@@ -248,7 +249,7 @@ private static async Task> CompressAsync(ConversionStep st
// The PDFs to compress: the file itself, or its selected pages copied into new PDFs.
var parts = new List<(string Plain, string? Suffix)>();
- using (var source = Open(step.InputPath, PdfDocumentOpenMode.Import))
+ using (var source = Open(step.InputPath, PdfDocumentOpenMode.Import, ct))
{
var pages = PageRange.Parse(options.PageRange, source.PageCount);
if (options.SplitPages)
@@ -314,7 +315,7 @@ private static async Task CompressFileAsync(string plain, string output, P
var builtIn = Path.Combine(scratch, Guid.NewGuid().ToString("N")[..8] + "-filee.pdf");
try
{
- using var document = OpenForChanges(plain);
+ using var document = OpenForChanges(plain, ct);
PdfCompressor.Compress(document, level, preset.Image.KeepMetadata, ct);
document.Save(builtIn);
candidates.Add(builtIn);
@@ -357,15 +358,15 @@ private static async Task CompressFileAsync(string plain, string output, P
/// Opens a PDF to change it. One locked only against changes (it opens without a password) has its pages copied
/// into a new PDF instead, as when pages are split or extracted: bookmarks and form fields stay behind then.
///
- internal static PdfDocument OpenForChanges(string path)
+ internal static PdfDocument OpenForChanges(string path, CancellationToken cancellationToken = default)
{
try
{
- return PdfReader.Open(path, PdfDocumentOpenMode.Modify);
+ return OpenTolerant(path, PdfDocumentOpenMode.Modify, cancellationToken, out _);
}
catch (PdfReaderException ex) when (ex.Message.Contains("owner password", StringComparison.OrdinalIgnoreCase))
{
- using var source = Open(path, PdfDocumentOpenMode.Import);
+ using var source = Open(path, PdfDocumentOpenMode.Import, cancellationToken);
var copy = new PdfDocument();
foreach (var page in source.Pages)
copy.AddPage(page);
@@ -384,13 +385,49 @@ internal static void CopyWritable(string source, string destination)
File.SetAttributes(destination, File.GetAttributes(destination) & ~FileAttributes.ReadOnly);
}
- /// Opens a PDF; a password-protected one fails with the message the other PDF conversions give.
- internal static PdfDocument Open(string path, PdfDocumentOpenMode mode)
+ ///
+ /// Opens a PDF. A file PDFsharp reads is opened as it is; one it refuses gets one mended try (,
+ /// issue #1175), and when that does not help either the original error is thrown. The file on disk is never changed.
+ /// is the mended file when that was needed (else null), for code that reads the file a
+ /// second time (PDFsharp's XPdfForm) and must read the mended bytes, not the file.
+ ///
+ private static PdfDocument OpenTolerant(string path, PdfDocumentOpenMode mode, CancellationToken cancellationToken, out byte[]? mended)
{
+ mended = null;
try
{
return PdfReader.Open(path, mode);
}
+ catch (Exception ex) when (ex is not OperationCanceledException
+ && !ex.Message.Contains("password", StringComparison.OrdinalIgnoreCase))
+ {
+ if (PdfRepair.RepairFile(path, cancellationToken) is not { } repaired)
+ throw;
+ try
+ {
+ var document = PdfReader.Open(new MemoryStream(repaired), mode);
+ mended = repaired;
+ return document;
+ }
+ catch (Exception)
+ {
+ ExceptionDispatchInfo.Capture(ex).Throw(); // the original message is the useful one
+ throw;
+ }
+ }
+ }
+
+ /// Opens a PDF; a password-protected one fails with the message the other PDF conversions give.
+ internal static PdfDocument Open(string path, PdfDocumentOpenMode mode, CancellationToken cancellationToken = default) =>
+ Open(path, mode, out _, cancellationToken);
+
+ /// As above; is the mended bytes of a file PDFsharp refused as it was, else null.
+ internal static PdfDocument Open(string path, PdfDocumentOpenMode mode, out byte[]? mended, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ return OpenTolerant(path, mode, cancellationToken, out mended);
+ }
catch (PdfReaderException ex) when (ex.Message.Contains("password", StringComparison.OrdinalIgnoreCase))
{
throw PdfDocumentReader.PasswordProtected();
diff --git a/src/Filee.Engines/Pdf/PdfSheets.cs b/src/Filee.Engines/Pdf/PdfSheets.cs
index a3778e398..3c91d9627 100644
--- a/src/Filee.Engines/Pdf/PdfSheets.cs
+++ b/src/Filee.Engines/Pdf/PdfSheets.cs
@@ -100,8 +100,11 @@ internal static IReadOnlyList Cells(XSize sheet, PdfOptions options, int
///
internal static void Write(string input, string output, PdfOptions options, IProgress? progress, CancellationToken cancellationToken)
{
+ // A PDF PDFsharp refuses (issue #1175) comes back mended in memory; XPdfForm then reads those bytes, not the file.
+ // A password-protected one fails here, with the message the other PDF tools give.
List<(int Index, ShownPage Shown)> pages;
- using (var source = PdfSharpConverter.Open(input, PdfDocumentOpenMode.Import))
+ byte[]? mended;
+ using (var source = PdfSharpConverter.Open(input, PdfDocumentOpenMode.Import, out mended, cancellationToken))
pages = [.. PageRange.Parse(options.PageRange, source.PageCount).Select(i => (i, Shown(source.Pages[i])))];
if (pages.Count == 0)
throw new InvalidOperationException("The PDF has no pages in the chosen range.");
@@ -109,7 +112,7 @@ internal static void Write(string input, string output, PdfOptions options, IPro
var perSheet = Math.Clamp(options.PagesPerSheet, 2, 16);
var sheet = SheetSize(options, perSheet, pages[0].Shown);
var cells = Cells(sheet, options, perSheet);
- using var form = XPdfForm.FromFile(input);
+ using var form = mended is null ? XPdfForm.FromFile(input) : XPdfForm.FromStream(new MemoryStream(mended));
using var document = new PdfDocument();
for (var first = 0; first < pages.Count; first += perSheet)
{
diff --git a/tests/Filee.Engines.Tests/PdfRepairTests.cs b/tests/Filee.Engines.Tests/PdfRepairTests.cs
new file mode 100644
index 000000000..b554f2068
--- /dev/null
+++ b/tests/Filee.Engines.Tests/PdfRepairTests.cs
@@ -0,0 +1,296 @@
+using System.Text;
+using Filee.Core.Presets;
+using Filee.Engines.Pdf;
+using PdfSharp.Pdf;
+using PdfSharp.Pdf.IO;
+
+namespace Filee.Engines.Tests;
+
+/// Issue #1175: a PDF with an object (the bookmarks in the original file) closed by one ">>" too many.
+public class PdfRepairTests
+{
+ private const string Catalog = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n";
+ private const string DamagedCatalog = "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\n>>\nendobj\n";
+
+ /// A one-page PDF with a classic xref table that lists the objects in after the four it has.
+ private static byte[] Pdf(string catalog, params string[] extra)
+ {
+ const string content = "0 0 100 100 re f";
+ string[] bodies =
+ [
+ catalog,
+ "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
+ "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R >>\nendobj\n",
+ $"4 0 obj\n<< /Length {content.Length} >>\nstream\n{content}\nendstream\nendobj\n",
+ .. extra,
+ ];
+
+ var text = new StringBuilder("%PDF-1.4\n");
+ var offsets = new List();
+ foreach (var body in bodies)
+ {
+ offsets.Add(text.Length);
+ text.Append(body);
+ }
+
+ var xref = text.Length;
+ text.Append("xref\n0 ").Append(bodies.Length + 1).Append('\n');
+ text.Append("0000000000 65535 f\r\n");
+ foreach (var offset in offsets)
+ text.Append($"{offset:D10} 00000 n\r\n");
+ text.Append($"trailer\n<< /Size {bodies.Length + 1} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n");
+ return Encoding.Latin1.GetBytes(text.ToString());
+ }
+
+ private static byte[] DamagedPdf() => Pdf(DamagedCatalog);
+
+ private static string Latin1(byte[] bytes) => Encoding.Latin1.GetString(bytes);
+
+ /// Writes a real password-protected PDF (PDFsharp's own encryption) to .
+ private static void WritePasswordProtectedPdf(string path)
+ {
+ using var document = new PdfDocument();
+ document.AddPage();
+ document.SecuritySettings.UserPassword = "user-secret";
+ document.SecuritySettings.OwnerPassword = "owner-secret";
+ document.Save(path);
+ }
+
+ private static string TempPdf() => Path.Combine(Path.GetTempPath(), $"filee-test-{Guid.NewGuid():N}.pdf");
+
+ [Fact]
+ public void Repair_overwrites_the_surplus_close_and_moves_nothing()
+ {
+ var damaged = DamagedPdf();
+ var repaired = PdfRepair.Repair(damaged, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(repaired);
+ Assert.Equal(damaged.Length, repaired.Length); // same length, so the file's own xref table stays valid
+ using var document = PdfReader.Open(new MemoryStream(repaired), PdfDocumentOpenMode.Import);
+ Assert.Equal(1, document.PageCount);
+ }
+
+ [Fact]
+ public void Repair_leaves_a_file_without_damage_as_it_is()
+ {
+ Assert.Null(PdfRepair.Repair(Pdf(Catalog), TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public void Repair_does_not_count_close_marks_inside_a_string()
+ {
+ const string bookmark = "5 0 obj\n<< /Title (Part A >> Part B) /Parent 2 0 R >>\nendobj\n";
+
+ Assert.Null(PdfRepair.Repair(Pdf(Catalog, bookmark), TestContext.Current.CancellationToken));
+
+ var repaired = PdfRepair.Repair(Pdf(DamagedCatalog, bookmark), TestContext.Current.CancellationToken);
+ Assert.NotNull(repaired);
+ Assert.Contains("(Part A >> Part B)", Latin1(repaired), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Repair_only_looks_at_objects_the_xref_table_lists()
+ {
+ // an attached file inside a stream holds text that looks like a damaged object, but the xref table does not list it
+ const string inner = "7 0 obj\n<< /A 1 >>\n>>\nendobj\n";
+ var attached = $"5 0 obj\n<< /Length {inner.Length} >>\nstream\n{inner}\nendstream\nendobj\n";
+
+ var repaired = PdfRepair.Repair(Pdf(DamagedCatalog, attached), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(repaired);
+ Assert.Contains(inner, Latin1(repaired), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Repair_gives_up_on_a_truncated_file()
+ {
+ var damaged = DamagedPdf();
+ Assert.Null(PdfRepair.Repair(damaged[..(damaged.Length / 2)], TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task Merge_takes_the_damaged_pdf()
+ {
+ var damaged = TempPdf();
+ var output = TempPdf();
+ try
+ {
+ await File.WriteAllBytesAsync(damaged, DamagedPdf(), TestContext.Current.CancellationToken);
+ await new PdfSharpConverter().MergeAsync([damaged, damaged], output, TestContext.Current.CancellationToken);
+
+ using var merged = PdfReader.Open(output, PdfDocumentOpenMode.Import);
+ Assert.Equal(2, merged.PageCount);
+ }
+ finally
+ {
+ File.Delete(damaged);
+ File.Delete(output);
+ }
+ }
+
+ [Fact]
+ public void Pages_per_sheet_takes_the_damaged_pdf()
+ {
+ var damaged = TempPdf();
+ var output = TempPdf();
+ try
+ {
+ File.WriteAllBytes(damaged, DamagedPdf());
+ PdfSheets.Write(damaged, output, new PdfOptions { PagesPerSheet = 2 }, null, TestContext.Current.CancellationToken);
+
+ using var sheets = PdfReader.Open(output, PdfDocumentOpenMode.Import);
+ Assert.Equal(1, sheets.PageCount);
+ }
+ finally
+ {
+ File.Delete(damaged);
+ File.Delete(output);
+ }
+ }
+
+ [Fact]
+ public void A_healthy_file_stays_as_it_is_even_with_an_attached_pdf_that_has_a_lone_CR()
+ {
+ // an uncompressed attached PDF whose "stream" keyword is followed by a bare CR
+ var attached = Encoding.Latin1.GetBytes("%PDF-1.4\r1 0 obj\r<< /Length 3 >>\rstream\rabc\rendstream\rendobj\r");
+ var path = TempPdf();
+ try
+ {
+ using (var document = new PdfDocument())
+ {
+ for (var i = 0; i < 3; i++)
+ document.AddPage();
+ var file = new PdfDictionary(document);
+ file.CreateStream(attached);
+ document.Internals.AddObject(file);
+ document.Internals.Catalog.Elements.Add("/Attached", file.Reference!);
+ document.Save(path);
+ }
+
+ Assert.Null(PdfRepair.Repair(File.ReadAllBytes(path), TestContext.Current.CancellationToken));
+ using var opened = PdfSharpConverter.Open(path, PdfDocumentOpenMode.Import, TestContext.Current.CancellationToken);
+ Assert.Equal(3, opened.PageCount);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void A_password_protected_pdf_is_reported_and_not_repaired()
+ {
+ var path = TempPdf();
+ try
+ {
+ WritePasswordProtectedPdf(path);
+
+ Assert.Null(PdfRepair.Repair(File.ReadAllBytes(path), TestContext.Current.CancellationToken));
+ Assert.Throws(() => PdfSharpConverter.Open(path, PdfDocumentOpenMode.Import, TestContext.Current.CancellationToken));
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void Pages_per_sheet_reports_a_password_protected_pdf_as_the_other_pdf_tools_do()
+ {
+ var path = TempPdf();
+ var output = TempPdf();
+ try
+ {
+ WritePasswordProtectedPdf(path);
+
+ var error = Assert.Throws(() =>
+ PdfSheets.Write(path, output, new PdfOptions { PagesPerSheet = 2 }, null, TestContext.Current.CancellationToken));
+
+ Assert.Equal(PdfDocumentReader.PasswordProtected().Message, error.Message);
+ Assert.False(File.Exists(output));
+ }
+ finally
+ {
+ File.Delete(path);
+ File.Delete(output);
+ }
+ }
+
+ [Fact]
+ public void Repair_mends_a_damaged_bookmark_whose_title_has_the_word_stream_in_it()
+ {
+ // "stream" inside a string is not a stream keyword: the object has no stream, so its surplus ">>" is mended
+ const string bookmark = "5 0 obj\n<< /Title (Upstream plan) /Parent 2 0 R >>\n>>\nendobj\n";
+ var damaged = Pdf(Catalog, bookmark);
+
+ var repaired = PdfRepair.Repair(damaged, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(repaired);
+ Assert.Equal(damaged.Length, repaired.Length);
+ var text = Latin1(repaired);
+ Assert.Contains("(Upstream plan)", text, StringComparison.Ordinal);
+ Assert.DoesNotContain(">>\n>>\nendobj", text, StringComparison.Ordinal);
+ using var document = PdfReader.Open(new MemoryStream(repaired), PdfDocumentOpenMode.Import);
+ Assert.Equal(1, document.PageCount);
+ }
+
+ [Fact]
+ public void Repair_leaves_an_object_with_a_stream_alone()
+ {
+ // a real "stream" keyword: the object is not judged, even though its dictionary has a ">>" too many
+ const string data = "abc";
+ var withStream = $"5 0 obj\n<< /Length {data.Length} >>\n>>\nstream\n{data}\nendstream\nendobj\n";
+
+ Assert.Null(PdfRepair.Repair(Pdf(Catalog, withStream), TestContext.Current.CancellationToken));
+ }
+
+ [Theory]
+ [InlineData("stream \nabc>>\nendstream")] // a space before the line end
+ [InlineData("stream abc>>\nendstream")] // no line end at all
+ public void Repair_leaves_a_stream_alone_when_its_keyword_is_written_loosely(string stream)
+ {
+ // what the stream holds must never be counted, or the ">>" in its data is overwritten
+ var withStream = $"5 0 obj\n<< /Length 5 >>\n{stream}\nendobj\n";
+
+ Assert.Null(PdfRepair.Repair(Pdf(Catalog, withStream), TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public void Repair_can_be_cancelled_on_a_file_with_many_objects()
+ {
+ // 20,000 listed objects that never end: the repair gives up on them, and a cancelled token stops it
+ var objects = Enumerable.Range(5, 20_000).Select(n => $"{n} 0 obj\n<< /A 1 >>\n").ToArray();
+ var pdf = Pdf(Catalog, objects);
+
+ Assert.Null(PdfRepair.Repair(pdf, TestContext.Current.CancellationToken));
+ Assert.Throws(() => PdfRepair.Repair(pdf, new CancellationToken(canceled: true)));
+ }
+
+ [Fact]
+ public void Open_hands_back_the_mended_bytes_only_for_a_file_PDFsharp_refused()
+ {
+ var damaged = TempPdf();
+ var healthy = TempPdf();
+ try
+ {
+ File.WriteAllBytes(damaged, DamagedPdf());
+ File.WriteAllBytes(healthy, Pdf(Catalog));
+
+ using (var document = PdfSharpConverter.Open(damaged, PdfDocumentOpenMode.Import, out var mended, TestContext.Current.CancellationToken))
+ {
+ Assert.NotNull(mended);
+ Assert.Equal(1, document.PageCount);
+ }
+ using (var document = PdfSharpConverter.Open(healthy, PdfDocumentOpenMode.Import, out var mended, TestContext.Current.CancellationToken))
+ {
+ Assert.Null(mended);
+ Assert.Equal(1, document.PageCount);
+ }
+ }
+ finally
+ {
+ File.Delete(damaged);
+ File.Delete(healthy);
+ }
+ }
+}