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