From 934859b7d60fbf3bcc4cb869f2d6276af12d1b01 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Thu, 5 Nov 2026 12:51:28 +0000 Subject: [PATCH 01/12] Add files via upload (new) fixes both defects in memory and rebuilds the xref table. It skips encrypted files and files that use object streams. Your file on disk is never modified. --- src/Filee.Engines/Pdf/PdfRepair.cs | 119 +++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/Filee.Engines/Pdf/PdfRepair.cs diff --git a/src/Filee.Engines/Pdf/PdfRepair.cs b/src/Filee.Engines/Pdf/PdfRepair.cs new file mode 100644 index 000000000..f052ae935 --- /dev/null +++ b/src/Filee.Engines/Pdf/PdfRepair.cs @@ -0,0 +1,119 @@ +// Fixes two kinds of 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: +// 1. "stream" followed by a lone CR. The spec allows only CRLF or LF there. +// 2. An object closed with one ">>" too many (the bookmark entries in the file from #1175). +// The repair works on the bytes in memory and writes a fresh classic xref table, so the file on disk is never touched. +// Files it cannot judge (encrypted, or using object / xref streams) are returned as null and fail as before. + +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace Filee.Engines.Pdf; + +internal static class PdfRepair +{ + /// Above this size the repair is not tried (it holds the file as text). + private const int MaxBytes = 256 * 1024 * 1024; + + private static readonly Encoding Latin1 = Encoding.Latin1; + + private static readonly Regex LoneCrStream = new(@"(?<=>)[ \t\r\n]*stream\r(?!\n)", RegexOptions.CultureInvariant); + private static readonly Regex ObjectBody = new(@"((?:^|(?<=[\r\n]))\d+ \d+ obj)(.*?)(endobj)", RegexOptions.Singleline | RegexOptions.CultureInvariant); + private static readonly Regex ObjectHeader = new(@"(?:^|(?<=[\r\n]))(\d+) (\d+) obj\b", RegexOptions.CultureInvariant); + private static readonly Regex RootRef = new(@"/Root\s+(\d+)\s+(\d+)\s+R", RegexOptions.CultureInvariant); + private static readonly Regex InfoRef = new(@"/Info\s+(\d+)\s+(\d+)\s+R", RegexOptions.CultureInvariant); + + /// True when a "stream" keyword is followed by a lone CR: cheap to check, so it runs on every file. + public static bool HasLoneCrStream(ReadOnlySpan pdf) + { + ReadOnlySpan keyword = "stream\r"u8; + var from = 0; + while (from < pdf.Length) + { + var found = pdf[from..].IndexOf(keyword); + if (found < 0) + return false; + var at = from + found; + var next = at + keyword.Length; + var isEndstream = at >= 3 && pdf[at - 3] == (byte)'e' && pdf[at - 2] == (byte)'n' && pdf[at - 1] == (byte)'d'; + if (!isEndstream && (next >= pdf.Length || pdf[next] != (byte)'\n')) + return true; + from = next; + } + return false; + } + + /// The repaired PDF, or null when the file is out of reach of this repair. + public static byte[]? Repair(byte[] pdf) + { + if (pdf.Length is 0 or > MaxBytes) + return null; + + var text = Latin1.GetString(pdf); // one char per byte, so a string index is a file offset + if (text.Contains("/Encrypt", StringComparison.Ordinal) || text.Contains("/ObjStm", StringComparison.Ordinal) + || text.Contains("/XRef", StringComparison.Ordinal)) + return null; + + text = LoneCrStream.Replace(text, match => match.Value + "\n"); + text = ObjectBody.Replace(text, DropSurplusClose); + + var objects = new SortedDictionary(); + foreach (Match match in ObjectHeader.Matches(text)) + objects[int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture)] = + (match.Index, int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture)); // the last copy of an object wins + + var root = RootRef.Matches(text).LastOrDefault(); + if (objects.Count == 0 || root is null) + return null; + var info = InfoRef.Matches(text).LastOrDefault(); + + var size = objects.Keys.Max() + 1; + var xrefOffset = text.Length; + var table = new StringBuilder(); + table.Append("\nxref\n0 ").Append(size).Append('\n'); + table.Append("0000000000 65535 f\r\n"); + for (var number = 1; number < size; number++) + { + if (objects.TryGetValue(number, out var entry)) + table.Append(entry.Offset.ToString("D10", CultureInfo.InvariantCulture)).Append(' ') + .Append(entry.Generation.ToString("D5", CultureInfo.InvariantCulture)).Append(" n\r\n"); + else + table.Append("0000000000 00000 f\r\n"); + } + xrefOffset += 1; // the leading "\n" of the table + table.Append("trailer\n<< /Size ").Append(size) + .Append(" /Root ").Append(root.Groups[1].Value).Append(' ').Append(root.Groups[2].Value).Append(" R"); + if (info is not null) + table.Append(" /Info ").Append(info.Groups[1].Value).Append(' ').Append(info.Groups[2].Value).Append(" R"); + table.Append(" >>\nstartxref\n").Append(xrefOffset.ToString(CultureInfo.InvariantCulture)).Append("\n%%EOF\n"); + + return Latin1.GetBytes(text + table); + } + + /// An object without a stream that has more ">>" than "<<" loses the surplus from its end. + private static string DropSurplusClose(Match match) + { + var body = match.Groups[2].Value; + if (body.Contains("stream", StringComparison.Ordinal)) + return match.Value; + + var open = Count(body, "<<"); + var close = Count(body, ">>"); + if (close <= open) + return match.Value; + + var builder = new StringBuilder(body); + for (; close > open; close--) + builder.Remove(builder.ToString().LastIndexOf(">>", StringComparison.Ordinal), 2); + return match.Groups[1].Value + builder + match.Groups[3].Value; + } + + private static int Count(string text, string token) + { + var count = 0; + for (var at = text.IndexOf(token, StringComparison.Ordinal); at >= 0; at = text.IndexOf(token, at + token.Length, StringComparison.Ordinal)) + count++; + return count; + } +} From 9a1d8b38d5b6d25cde8e78ece5b054d30600bca3 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Thu, 5 Nov 2026 12:52:01 +0000 Subject: [PATCH 02/12] Add files via upload (patched) has a new OpenTolerant method. Both open paths use it. It repairs files with the bare-CR problem up front, and gives any other file PDFsharp refuses one repair attempt. --- src/Filee.Engines/Pdf/PdfSharpConverter.cs | 72 +++++++++++++++++++--- 1 file changed, 65 insertions(+), 7 deletions(-) diff --git a/src/Filee.Engines/Pdf/PdfSharpConverter.cs b/src/Filee.Engines/Pdf/PdfSharpConverter.cs index beecde34d..40713efc9 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; @@ -37,6 +38,7 @@ public sealed class PdfSharpConverter : IConverter, IPdfMerger, IPdfFinisher, II public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => step.From != "pdf" ? Task.Run(() => ImageToPdf(step, progress, cancellationToken), cancellationToken) + : step.Preset.Pdf.MakesSheets ? Task.Run(() => Sheets(step, progress, cancellationToken), cancellationToken) : step.Preset.Pdf is { Compression: PdfCompression.None, RemoveMetadata: true, SplitPages: false, PageRange: "" } ? Task.Run(() => WithoutMetadata(step, progress, cancellationToken), cancellationToken) : step.Preset.Pdf.Compression == PdfCompression.None ? Task.Run(() => SplitOrExtract(step, progress, cancellationToken), cancellationToken) @@ -79,8 +81,6 @@ private static IReadOnlyList ImageToPdf(ConversionStep step, IProgress ImageToPdf(ConversionStep step, IProgress ImageToPdf(ConversionStep step, IProgress"Pages per sheet" (PdfSheets): the pages of the range, several to a sheet, in one new PDF. + private static IReadOnlyList Sheets(ConversionStep step, IProgress? progress, CancellationToken ct) + { + var output = step.Output.Allocate("pdf"); + if (output is null) + return []; + PdfSheets.Write(step.InputPath, output, step.Preset.Pdf, progress, ct); + return [output]; + } + /// /// "Remove metadata": the whole PDF, opened for changes so links, bookmarks and form fields stay, without its /// document information (title, author, subject, keywords, creator, dates) and XMP packets. @@ -341,7 +362,7 @@ internal static PdfDocument OpenForChanges(string path) { try { - return PdfReader.Open(path, PdfDocumentOpenMode.Modify); + return OpenTolerant(path, PdfDocumentOpenMode.Modify); } catch (PdfReaderException ex) when (ex.Message.Contains("owner password", StringComparison.OrdinalIgnoreCase)) { @@ -364,12 +385,44 @@ internal static void CopyWritable(string source, string destination) File.SetAttributes(destination, File.GetAttributes(destination) & ~FileAttributes.ReadOnly); } + /// + /// Opens a PDF, mending the damage some PDF writers leave first (, issue #1175). A file that + /// reads fine is opened as it is; one with a lone CR after "stream" is mended before PDFsharp sees it, and any other + /// file PDFsharp refuses gets one mended try. The file on disk is never changed. + /// + private static PdfDocument OpenTolerant(string path, PdfDocumentOpenMode mode) + { + var bytes = File.ReadAllBytes(path); + if (PdfRepair.HasLoneCrStream(bytes) && PdfRepair.Repair(bytes) is { } mended) + return PdfReader.Open(new MemoryStream(mended), mode); + + try + { + return PdfReader.Open(new MemoryStream(bytes), mode); + } + catch (Exception ex) when (ex is not OperationCanceledException + && !ex.Message.Contains("password", StringComparison.OrdinalIgnoreCase)) + { + if (PdfRepair.Repair(bytes) is not { } repaired) + throw; + try + { + return PdfReader.Open(new MemoryStream(repaired), mode); + } + 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) { try { - return PdfReader.Open(path, mode); + return OpenTolerant(path, mode); } catch (PdfReaderException ex) when (ex.Message.Contains("password", StringComparison.OrdinalIgnoreCase)) { @@ -383,10 +436,12 @@ internal static PdfDocument Open(string path, PdfDocumentOpenMode mode) /// private static MemoryStream EncodeForPdf(IMagickImage frame, string sourceFormat, ImageOptions options) { + // PDFsharp takes grey and RGB pictures; CMYK ones are placed by CmykPictures, this is only a safety net (#1055). + PdfPictures.ToRgb(frame); var stream = new MemoryStream(); if (sourceFormat == "jpg" && !frame.HasAlpha) { - frame.Quality = (uint)Math.Clamp(Math.Max(options.Quality, 85), 1, 100); + frame.Quality = JpegQuality(options); frame.Write(stream, MagickFormat.Jpeg); } else @@ -397,6 +452,9 @@ private static MemoryStream EncodeForPdf(IMagickImage frame, string source return stream; } + /// A JPEG source goes into the PDF as JPEG again, at the preset's quality but never below 85. + private static uint JpegQuality(ImageOptions options) => (uint)Math.Clamp(Math.Max(options.Quality, 85), 1, 100); + private static (double Width, double Height) PageSize(PdfPageSize size, bool landscape) { var (w, h) = size switch From 22403343d9e27438a1eb82a32a7af428e612e3a6 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Thu, 5 Nov 2026 12:52:44 +0000 Subject: [PATCH 03/12] Add files via upload (new) builds a small PDF with both defects and checks detection, repair, merge and the encrypted-file bail-out. --- tests/Filee.Engines.Tests/PdfRepairTests.cs | 81 +++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/Filee.Engines.Tests/PdfRepairTests.cs diff --git a/tests/Filee.Engines.Tests/PdfRepairTests.cs b/tests/Filee.Engines.Tests/PdfRepairTests.cs new file mode 100644 index 000000000..6c4574824 --- /dev/null +++ b/tests/Filee.Engines.Tests/PdfRepairTests.cs @@ -0,0 +1,81 @@ +using System.Text; +using Filee.Engines.Pdf; +using PdfSharp.Pdf; +using PdfSharp.Pdf.IO; + +namespace Filee.Engines.Tests; + +/// Issue #1175: a PDF with a lone CR after "stream" and a bookmark closed by one ">>" too many. +public class PdfRepairTests +{ + /// One A4 page whose content stream follows the keyword with a bare CR, and a catalog with an extra ">>". + private static byte[] DamagedPdf() + { + var text = new StringBuilder("%PDF-1.7\r"); + var offsets = new List(); + void Add(string body) + { + offsets.Add(text.Length); + text.Append(body); + } + + Add("1 0 obj\r<>\r>>\rendobj\r"); + Add("2 0 obj\r<>\rendobj\r"); + Add("3 0 obj\r<>\rendobj\r"); + const string content = "0 0 100 100 re f"; + Add($"4 0 obj\r<< /Length {content.Length} >>\rstream\r{content}\rendstream\rendobj\r"); + + var xref = text.Length; + text.Append("xref\r0 5 \r0000000000 65535 f\r\n"); + foreach (var offset in offsets) + text.Append($"{offset:D10} 00000 n\r\n"); + text.Append($"trailer\r<>\rstartxref\r{xref}\r%%EOF\r"); + return Encoding.Latin1.GetBytes(text.ToString()); + } + + [Fact] + public void Finds_a_lone_carriage_return_after_stream() + { + Assert.True(PdfRepair.HasLoneCrStream(DamagedPdf())); + Assert.False(PdfRepair.HasLoneCrStream("x << /Length 1 >>\nstream\nA\nendstream\r\nendobj"u8)); + Assert.False(PdfRepair.HasLoneCrStream("x << /Length 1 >>\r\nstream\r\nA\rendstream\rendobj"u8)); + } + + [Fact] + public void Repair_leaves_a_pdf_that_PDFsharp_reads() + { + var repaired = PdfRepair.Repair(DamagedPdf()); + Assert.NotNull(repaired); + Assert.False(PdfRepair.HasLoneCrStream(repaired)); + + using var document = PdfReader.Open(new MemoryStream(repaired), PdfDocumentOpenMode.Import); + Assert.Equal(1, document.PageCount); + } + + [Fact] + public async Task Merge_takes_the_damaged_pdf() + { + var damaged = Path.Combine(Path.GetTempPath(), $"filee-damaged-{Guid.NewGuid():N}.pdf"); + var output = Path.Combine(Path.GetTempPath(), $"filee-merged-{Guid.NewGuid():N}.pdf"); + try + { + await File.WriteAllBytesAsync(damaged, DamagedPdf()); + await new PdfSharpConverter().MergeAsync([damaged, damaged], output, CancellationToken.None); + + using var merged = PdfReader.Open(output, PdfDocumentOpenMode.Import); + Assert.Equal(2, merged.PageCount); + } + finally + { + File.Delete(damaged); + File.Delete(output); + } + } + + [Fact] + public void Repair_gives_up_on_an_encrypted_file() + { + var text = Encoding.Latin1.GetString(DamagedPdf()).Replace("/Root 1 0 R", "/Root 1 0 R /Encrypt 9 0 R"); + Assert.Null(PdfRepair.Repair(Encoding.Latin1.GetBytes(text))); + } +} From 42c3733eb38d9d4a905e3b7e8d73076e441cc2ec Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Thu, 5 Nov 2026 14:11:53 +0000 Subject: [PATCH 04/12] UPDATE --- tests/Filee.Engines.Tests/PdfRepairTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Filee.Engines.Tests/PdfRepairTests.cs b/tests/Filee.Engines.Tests/PdfRepairTests.cs index 6c4574824..66f553dae 100644 --- a/tests/Filee.Engines.Tests/PdfRepairTests.cs +++ b/tests/Filee.Engines.Tests/PdfRepairTests.cs @@ -59,8 +59,8 @@ public async Task Merge_takes_the_damaged_pdf() var output = Path.Combine(Path.GetTempPath(), $"filee-merged-{Guid.NewGuid():N}.pdf"); try { - await File.WriteAllBytesAsync(damaged, DamagedPdf()); - await new PdfSharpConverter().MergeAsync([damaged, damaged], output, CancellationToken.None); + 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); From 2145a59c3dea2a6ff6ae2848c2bd85e54f1fe76d Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Thu, 5 Nov 2026 14:12:32 +0000 Subject: [PATCH 05/12] update x2 --- tests/Filee.Core.Tests/MergeImagesTests.cs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/Filee.Core.Tests/MergeImagesTests.cs b/tests/Filee.Core.Tests/MergeImagesTests.cs index 265d8c11a..151cbed6d 100644 --- a/tests/Filee.Core.Tests/MergeImagesTests.cs +++ b/tests/Filee.Core.Tests/MergeImagesTests.cs @@ -3,6 +3,7 @@ using System.Text.Json; using Filee.Core.Conversion; +using Filee.Core.Platform; using Filee.Core.Presets; using Filee.Core.Profiles; using Filee.Core.Settings; @@ -19,7 +20,7 @@ public class MergeImagesTests "compress-image", "remove-metadata", "zip-all"]; [Fact] - public void A_new_library_merges_pictures_in_their_first_format_next_to_merge_pdf() + public void A_new_library_merges_pictures_in_their_first_format_before_compression() { var merge = BuiltIn("merge-images"); Assert.Equal("preset.merge_images", merge.NameKey); @@ -31,9 +32,9 @@ public void A_new_library_merges_pictures_in_their_first_format_next_to_merge_pd (merge.Image.JoinDirection, merge.Image.JoinFit, merge.Image.JoinGap, merge.Image.JoinBackground)); var back = BuiltInData.CreateProfiles().Single(p => p.Id == "images").BackPresetIds!; - Assert.Equal(ToolbarProfile.MaxSlices, back.Count); - Assert.Equal(["to-avif", "merge-images", "merge-pdf"], back[6..9]); - // GIF gave its slot: still a preset of the library, and on the video donut's front. + Assert.Equal(8, back.Count); + Assert.Equal(["remove-background", "merge-images", "compress-image"], back[2..5]); + // GIF is a front-face conversion; merging stays among the back-face edits. Assert.DoesNotContain("to-gif", back); Assert.Single(BuiltInData.CreatePresets(), p => p.Id == "to-gif"); Assert.Equal(["merge-images"], BuiltInData.CreatePresets().Where(p => p.JoinsImages).Select(p => p.Id)); @@ -70,13 +71,13 @@ public void The_gap_stays_within_its_limits() public void Schema_53_puts_merge_images_in_place_of_gif_on_a_full_images_back_face() { var presets = BuiltInData.CreatePresets().Where(p => p.Id != "merge-images").ToList(); - var profiles = BuiltInData.CreateProfiles(); + var profiles = BuiltInData.ProfilesSchema58(); var images = profiles.Single(p => p.Id == "images"); images.BackPresetIds = [.. BackBefore53]; Assert.True(SettingsMigrations.ApplyToLibrary(50, presets, profiles)); - Assert.Equal(BuiltInData.CreateProfiles().Single(p => p.Id == "images").BackPresetIds, images.BackPresetIds); + Assert.Equal(BuiltInData.ProfilesSchema58().Single(p => p.Id == "images").BackPresetIds, images.BackPresetIds); // In the library after the media tools, as in a new one. Assert.Equal(presets.FindIndex(p => p.Id == "remove-metadata") + 1, presets.FindIndex(p => p.Id == "merge-images")); Assert.Single(presets, p => p.Id == "to-gif"); @@ -87,7 +88,7 @@ public void Schema_53_puts_merge_images_in_place_of_gif_on_a_full_images_back_fa public void Schema_53_takes_a_free_slot_before_merge_pdf_and_leaves_gif() { var presets = BuiltInData.CreatePresets().Where(p => p.Id != "merge-images").ToList(); - var profiles = BuiltInData.CreateProfiles(); + var profiles = BuiltInData.ProfilesSchema58(); var images = profiles.Single(p => p.Id == "images"); images.BackPresetIds = ["tool-crop", "to-gif", "merge-pdf", "zip-all"]; @@ -100,7 +101,7 @@ public void Schema_53_takes_a_free_slot_before_merge_pdf_and_leaves_gif() public void Schema_53_leaves_a_donut_where_the_user_put_merge_images_or_has_no_gif_to_give() { var presets = BuiltInData.CreatePresets(); - var profiles = BuiltInData.CreateProfiles(); + var profiles = BuiltInData.ProfilesSchema58(); var images = profiles.Single(p => p.Id == "images"); images.PresetIds = ["merge-images", "to-png"]; images.BackPresetIds = [.. BackBefore53]; @@ -123,7 +124,7 @@ public void Schema_53_puts_the_option_back_when_an_older_filee_dropped_it() var stripped = presets.Single(p => p.Id == "merge-images"); stripped.Image.JoinIntoOne = false; stripped.Name = "Stack my scans"; - var profiles = BuiltInData.CreateProfiles(); + var profiles = BuiltInData.ProfilesSchema58(); Assert.True(SettingsMigrations.ApplyToLibrary(50, presets, profiles)); @@ -201,7 +202,7 @@ public async Task A_picture_the_joiner_cannot_read_becomes_a_plain_png_and_a_fir Assert.Equal("png", joiner.Format); Assert.Equal(2, joiner.Inputs.Count); Assert.Equal("png", joiner.Inputs[0].Format); - Assert.StartsWith(Path.Combine(Path.GetTempPath(), "Filee"), joiner.Inputs[0].Path); // in the job's work folder + Assert.StartsWith(WorkFolders.Root, joiner.Inputs[0].Path); // in the job's work folder Assert.Equal((photo, "jpg"), joiner.Inputs[1]); var step = Assert.Single(engine.Presets); Assert.Equal((ResizeMode.None, ImageRotation.None, false), (step.Image.Resize, step.Image.Rotation, step.Image.JoinIntoOne)); From 4568d4019c09b65d03560ce032f6ab4703b19802 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Fri, 6 Nov 2026 11:42:04 +0000 Subject: [PATCH 06/12] update --- src/Filee.Engines/Pdf/PdfRepair.cs | 249 ++++++++++++++++++++--------- 1 file changed, 169 insertions(+), 80 deletions(-) diff --git a/src/Filee.Engines/Pdf/PdfRepair.cs b/src/Filee.Engines/Pdf/PdfRepair.cs index f052ae935..2cc58808f 100644 --- a/src/Filee.Engines/Pdf/PdfRepair.cs +++ b/src/Filee.Engines/Pdf/PdfRepair.cs @@ -1,11 +1,13 @@ -// Fixes two kinds of 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: -// 1. "stream" followed by a lone CR. The spec allows only CRLF or LF there. -// 2. An object closed with one ">>" too many (the bookmark entries in the file from #1175). -// The repair works on the bytes in memory and writes a fresh classic xref table, so the file on disk is never touched. -// Files it cannot judge (encrypted, or using object / xref streams) are returned as null and fail as before. - -using System.Globalization; +// 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, 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. + using System.Text; using System.Text.RegularExpressions; @@ -18,102 +20,189 @@ internal static class PdfRepair private static readonly Encoding Latin1 = Encoding.Latin1; - private static readonly Regex LoneCrStream = new(@"(?<=>)[ \t\r\n]*stream\r(?!\n)", RegexOptions.CultureInvariant); - private static readonly Regex ObjectBody = new(@"((?:^|(?<=[\r\n]))\d+ \d+ obj)(.*?)(endobj)", RegexOptions.Singleline | RegexOptions.CultureInvariant); - private static readonly Regex ObjectHeader = new(@"(?:^|(?<=[\r\n]))(\d+) (\d+) obj\b", RegexOptions.CultureInvariant); - private static readonly Regex RootRef = new(@"/Root\s+(\d+)\s+(\d+)\s+R", RegexOptions.CultureInvariant); - private static readonly Regex InfoRef = new(@"/Info\s+(\d+)\s+(\d+)\s+R", RegexOptions.CultureInvariant); + private static readonly Regex PrevRef = new(@"/Prev\s+(\d+)", RegexOptions.CultureInvariant); - /// True when a "stream" keyword is followed by a lone CR: cheap to check, so it runs on every file. - public static bool HasLoneCrStream(ReadOnlySpan pdf) + /// Reads and repairs it; null when it is too big, unreadable, or out of reach of the repair. + public static byte[]? RepairFile(string path) { - ReadOnlySpan keyword = "stream\r"u8; - var from = 0; - while (from < pdf.Length) + try + { + return new FileInfo(path).Length is 0 or > MaxBytes ? null : Repair(File.ReadAllBytes(path)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - var found = pdf[from..].IndexOf(keyword); - if (found < 0) - return false; - var at = from + found; - var next = at + keyword.Length; - var isEndstream = at >= 3 && pdf[at - 3] == (byte)'e' && pdf[at - 2] == (byte)'n' && pdf[at - 1] == (byte)'d'; - if (!isEndstream && (next >= pdf.Length || pdf[next] != (byte)'\n')) - return true; - from = next; + return null; } - return false; } - /// The repaired PDF, or null when the file is out of reach of this repair. + /// 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) { if (pdf.Length is 0 or > MaxBytes) return null; var text = Latin1.GetString(pdf); // one char per byte, so a string index is a file offset - if (text.Contains("/Encrypt", StringComparison.Ordinal) || text.Contains("/ObjStm", StringComparison.Ordinal) - || text.Contains("/XRef", StringComparison.Ordinal)) + if (text.Contains("/Encrypt", StringComparison.Ordinal)) return null; - text = LoneCrStream.Replace(text, match => match.Value + "\n"); - text = ObjectBody.Replace(text, DropSurplusClose); + byte[]? repaired = null; + foreach (var offset in ListedObjects(text)) + { + foreach (var at in SurplusClose(text, offset)) + { + repaired ??= (byte[])pdf.Clone(); + repaired[at] = (byte)' '; + repaired[at + 1] = (byte)' '; + } + } + return repaired; + } - var objects = new SortedDictionary(); - foreach (Match match in ObjectHeader.Matches(text)) - objects[int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture)] = - (match.Index, int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture)); // the last copy of an object wins + /// 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(string text) + { + var found = new HashSet(); + var visited = new HashSet(); + var at = LastStartXref(text); + while (at is { } offset && visited.Add(offset)) + at = ReadTable(text, offset, found); + return found; + } + + private static int? LastStartXref(string text) + { + var keyword = text.LastIndexOf("startxref", StringComparison.Ordinal); + if (keyword < 0) + return null; + var pos = keyword + "startxref".Length; + return TryNumber(text, ref pos, out var offset) ? offset : null; + } - var root = RootRef.Matches(text).LastOrDefault(); - if (objects.Count == 0 || root is null) + /// Adds the listed objects of the table at to ; returns the /Prev table, if any. + private static int? ReadTable(string text, int offset, HashSet found) + { + if (offset < 0 || offset >= text.Length) return null; - var info = InfoRef.Matches(text).LastOrDefault(); - - var size = objects.Keys.Max() + 1; - var xrefOffset = text.Length; - var table = new StringBuilder(); - table.Append("\nxref\n0 ").Append(size).Append('\n'); - table.Append("0000000000 65535 f\r\n"); - for (var number = 1; number < size; number++) + var pos = offset; + SkipWhite(text, ref pos); + if (string.CompareOrdinal(text, pos, "xref", 0, 4) != 0) + return null; // an xref stream, or not a table at all + + pos += 4; + while (true) + { + SkipWhite(text, ref pos); + if (string.CompareOrdinal(text, pos, "trailer", 0, 7) == 0) + { + pos += 7; + break; + } + if (!TryNumber(text, ref pos, out _) || !TryNumber(text, ref pos, out var count)) + return null; + for (var i = 0; i < count; i++) + { + if (!TryNumber(text, ref pos, out var entryOffset) || !TryNumber(text, ref pos, out _)) + return null; + SkipWhite(text, ref pos); + if (pos >= text.Length) + return null; + if (text[pos++] == 'n' && entryOffset > 0) + found.Add(entryOffset); + } + } + + var end = text.IndexOf("startxref", pos, StringComparison.Ordinal); + var prev = PrevRef.Match(text, pos, (end < 0 ? text.Length : end) - pos); + return prev.Success && int.TryParse(prev.Groups[1].Value, out var previous) ? previous : null; + } + + /// + /// The offsets of the ">>" an object has more than "<<": the last ones. Objects with a stream are left alone, and + /// so are strings, hex strings and comments. + /// + private static List SurplusClose(string text, int offset) + { + var none = new List(); + var header = text.IndexOf("obj", offset, StringComparison.Ordinal); + if (header < 0 || header - offset > 24) + return none; + for (var i = offset; i < header; i++) // what sits at the offset must be "N G obj" + if (!char.IsAsciiDigit(text[i]) && text[i] != ' ') + return none; + + var start = header + 3; + var end = text.IndexOf("endobj", start, StringComparison.Ordinal); + if (end < 0 || text.AsSpan(start, end - start).IndexOf("stream", StringComparison.Ordinal) >= 0) + return none; + + var open = 0; + var closes = new List(); + for (var i = start; i < end; i++) + { + switch (text[i]) + { + case '(': + i = SkipLiteralString(text, i, end); + break; + case '%': + while (i + 1 < end && text[i + 1] is not ('\r' or '\n')) + i++; + break; + case '<' when i + 1 < end && text[i + 1] == '<': + open++; + i++; + break; + case '<': // a hex string + var greater = text.IndexOf('>', i, end - i); + if (greater < 0) + return none; + i = greater; + break; + case '>' when i + 1 < end && text[i + 1] == '>': + closes.Add(i); + i++; + break; + } + } + return closes.Count <= open ? none : closes.GetRange(open, closes.Count - open); + } + + /// The index of the ")" that ends the literal string opened at (parentheses nest, "\" escapes). + private static int SkipLiteralString(string text, int open, int end) + { + var depth = 1; + for (var i = open + 1; i < end; i++) { - if (objects.TryGetValue(number, out var entry)) - table.Append(entry.Offset.ToString("D10", CultureInfo.InvariantCulture)).Append(' ') - .Append(entry.Generation.ToString("D5", CultureInfo.InvariantCulture)).Append(" n\r\n"); - else - table.Append("0000000000 00000 f\r\n"); + switch (text[i]) + { + case '\\': + i++; + break; + case '(': + depth++; + break; + case ')': + if (--depth == 0) + return i; + break; + } } - xrefOffset += 1; // the leading "\n" of the table - table.Append("trailer\n<< /Size ").Append(size) - .Append(" /Root ").Append(root.Groups[1].Value).Append(' ').Append(root.Groups[2].Value).Append(" R"); - if (info is not null) - table.Append(" /Info ").Append(info.Groups[1].Value).Append(' ').Append(info.Groups[2].Value).Append(" R"); - table.Append(" >>\nstartxref\n").Append(xrefOffset.ToString(CultureInfo.InvariantCulture)).Append("\n%%EOF\n"); - - return Latin1.GetBytes(text + table); + return end; } - /// An object without a stream that has more ">>" than "<<" loses the surplus from its end. - private static string DropSurplusClose(Match match) + private static void SkipWhite(string text, ref int pos) { - var body = match.Groups[2].Value; - if (body.Contains("stream", StringComparison.Ordinal)) - return match.Value; - - var open = Count(body, "<<"); - var close = Count(body, ">>"); - if (close <= open) - return match.Value; - - var builder = new StringBuilder(body); - for (; close > open; close--) - builder.Remove(builder.ToString().LastIndexOf(">>", StringComparison.Ordinal), 2); - return match.Groups[1].Value + builder + match.Groups[3].Value; + while (pos < text.Length && text[pos] is ' ' or '\t' or '\r' or '\n' or '\f' or '\0') + pos++; } - private static int Count(string text, string token) + private static bool TryNumber(string text, ref int pos, out int value) { - var count = 0; - for (var at = text.IndexOf(token, StringComparison.Ordinal); at >= 0; at = text.IndexOf(token, at + token.Length, StringComparison.Ordinal)) - count++; - return count; + SkipWhite(text, ref pos); + var from = pos; + while (pos < text.Length && char.IsAsciiDigit(text[pos])) + pos++; + value = 0; + return pos > from && pos - from <= 10 && int.TryParse(text.AsSpan(from, pos - from), out value); } } From 886c142f0001186dd5ee5ac4821c08801f58a72c Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Fri, 6 Nov 2026 11:42:32 +0000 Subject: [PATCH 07/12] update --- src/Filee.Engines/Pdf/PdfSharpConverter.cs | 45 +++++++++++++++++----- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/Filee.Engines/Pdf/PdfSharpConverter.cs b/src/Filee.Engines/Pdf/PdfSharpConverter.cs index 40713efc9..68ea3714d 100644 --- a/src/Filee.Engines/Pdf/PdfSharpConverter.cs +++ b/src/Filee.Engines/Pdf/PdfSharpConverter.cs @@ -386,24 +386,19 @@ internal static void CopyWritable(string source, string destination) } /// - /// Opens a PDF, mending the damage some PDF writers leave first (, issue #1175). A file that - /// reads fine is opened as it is; one with a lone CR after "stream" is mended before PDFsharp sees it, and any other - /// file PDFsharp refuses gets one mended try. The file on disk is never changed. + /// 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. /// private static PdfDocument OpenTolerant(string path, PdfDocumentOpenMode mode) { - var bytes = File.ReadAllBytes(path); - if (PdfRepair.HasLoneCrStream(bytes) && PdfRepair.Repair(bytes) is { } mended) - return PdfReader.Open(new MemoryStream(mended), mode); - try { - return PdfReader.Open(new MemoryStream(bytes), mode); + return PdfReader.Open(path, mode); } catch (Exception ex) when (ex is not OperationCanceledException && !ex.Message.Contains("password", StringComparison.OrdinalIgnoreCase)) { - if (PdfRepair.Repair(bytes) is not { } repaired) + if (PdfRepair.RepairFile(path) is not { } repaired) throw; try { @@ -417,6 +412,38 @@ private static PdfDocument OpenTolerant(string path, PdfDocumentOpenMode mode) } } + /// + /// For code that needs a file path (PDFsharp's XPdfForm): a mended temporary copy of a PDF PDFsharp refuses, which the + /// caller deletes, or null when the file reads as it is or cannot be mended. + /// + internal static string? MendedCopy(string path) + { + try + { + using (PdfReader.Open(path, PdfDocumentOpenMode.Import)) + return null; + } + catch (Exception ex) when (ex is not OperationCanceledException + && !ex.Message.Contains("password", StringComparison.OrdinalIgnoreCase)) + { + if (PdfRepair.RepairFile(path) is not { } repaired) + return null; + try + { + using (PdfReader.Open(new MemoryStream(repaired), PdfDocumentOpenMode.Import)) + { + } + } + catch (Exception) + { + return null; + } + var copy = Path.Combine(Path.GetTempPath(), $"filee-mended-{Guid.NewGuid():N}.pdf"); + File.WriteAllBytes(copy, repaired); + return copy; + } + } + /// Opens a PDF; a password-protected one fails with the message the other PDF conversions give. internal static PdfDocument Open(string path, PdfDocumentOpenMode mode) { From d942f12c7dbadc867db5d608d34cb25f177b3684 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Fri, 6 Nov 2026 11:43:00 +0000 Subject: [PATCH 08/12] update --- src/Filee.Engines/Pdf/PdfSheets.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Filee.Engines/Pdf/PdfSheets.cs b/src/Filee.Engines/Pdf/PdfSheets.cs index a3778e398..52bb42813 100644 --- a/src/Filee.Engines/Pdf/PdfSheets.cs +++ b/src/Filee.Engines/Pdf/PdfSheets.cs @@ -99,6 +99,21 @@ internal static IReadOnlyList Cells(XSize sheet, PdfOptions options, int /// to a sheet, to . /// internal static void Write(string input, string output, PdfOptions options, IProgress? progress, CancellationToken cancellationToken) + { + // XPdfForm reads the file by its path, so a PDF PDFsharp refuses (issue #1175) is read from a mended copy. + var mended = PdfSharpConverter.MendedCopy(input); + try + { + WriteFrom(mended ?? input, output, options, progress, cancellationToken); + } + finally + { + if (mended is not null) + File.Delete(mended); + } + } + + private static void WriteFrom(string input, string output, PdfOptions options, IProgress? progress, CancellationToken cancellationToken) { List<(int Index, ShownPage Shown)> pages; using (var source = PdfSharpConverter.Open(input, PdfDocumentOpenMode.Import)) From 5d3ef5a1401a42db2cec8b7371d44047a3316363 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Fri, 6 Nov 2026 11:43:36 +0000 Subject: [PATCH 09/12] update --- tests/Filee.Engines.Tests/PdfRepairTests.cs | 165 ++++++++++++++++---- 1 file changed, 138 insertions(+), 27 deletions(-) diff --git a/tests/Filee.Engines.Tests/PdfRepairTests.cs b/tests/Filee.Engines.Tests/PdfRepairTests.cs index 66f553dae..38484a169 100644 --- a/tests/Filee.Engines.Tests/PdfRepairTests.cs +++ b/tests/Filee.Engines.Tests/PdfRepairTests.cs @@ -1,62 +1,108 @@ 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 a lone CR after "stream" and a bookmark closed by one ">>" too many. +/// Issue #1175: a PDF with an object (the bookmarks in the original file) closed by one ">>" too many. public class PdfRepairTests { - /// One A4 page whose content stream follows the keyword with a bare CR, and a catalog with an extra ">>". - private static byte[] DamagedPdf() + 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) { - var text = new StringBuilder("%PDF-1.7\r"); + 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(); - void Add(string body) + foreach (var body in bodies) { offsets.Add(text.Length); text.Append(body); } - Add("1 0 obj\r<>\r>>\rendobj\r"); - Add("2 0 obj\r<>\rendobj\r"); - Add("3 0 obj\r<>\rendobj\r"); - const string content = "0 0 100 100 re f"; - Add($"4 0 obj\r<< /Length {content.Length} >>\rstream\r{content}\rendstream\rendobj\r"); - var xref = text.Length; - text.Append("xref\r0 5 \r0000000000 65535 f\r\n"); + 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\r<>\rstartxref\r{xref}\r%%EOF\r"); + 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); + + 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); + + 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 Finds_a_lone_carriage_return_after_stream() + public void Repair_leaves_a_file_without_damage_as_it_is() { - Assert.True(PdfRepair.HasLoneCrStream(DamagedPdf())); - Assert.False(PdfRepair.HasLoneCrStream("x << /Length 1 >>\nstream\nA\nendstream\r\nendobj"u8)); - Assert.False(PdfRepair.HasLoneCrStream("x << /Length 1 >>\r\nstream\r\nA\rendstream\rendobj"u8)); + Assert.Null(PdfRepair.Repair(Pdf(Catalog))); } [Fact] - public void Repair_leaves_a_pdf_that_PDFsharp_reads() + public void Repair_does_not_count_close_marks_inside_a_string() { - var repaired = PdfRepair.Repair(DamagedPdf()); + 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))); + + var repaired = PdfRepair.Repair(Pdf(DamagedCatalog, bookmark)); Assert.NotNull(repaired); - Assert.False(PdfRepair.HasLoneCrStream(repaired)); + Assert.Contains("(Part A >> Part B)", Latin1(repaired), StringComparison.Ordinal); + } - using var document = PdfReader.Open(new MemoryStream(repaired), PdfDocumentOpenMode.Import); - Assert.Equal(1, document.PageCount); + [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)); + + 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)])); } [Fact] public async Task Merge_takes_the_damaged_pdf() { - var damaged = Path.Combine(Path.GetTempPath(), $"filee-damaged-{Guid.NewGuid():N}.pdf"); - var output = Path.Combine(Path.GetTempPath(), $"filee-merged-{Guid.NewGuid():N}.pdf"); + var damaged = TempPdf(); + var output = TempPdf(); try { await File.WriteAllBytesAsync(damaged, DamagedPdf(), TestContext.Current.CancellationToken); @@ -73,9 +119,74 @@ public async Task Merge_takes_the_damaged_pdf() } [Fact] - public void Repair_gives_up_on_an_encrypted_file() + public void Pages_per_sheet_takes_the_damaged_pdf() { - var text = Encoding.Latin1.GetString(DamagedPdf()).Replace("/Root 1 0 R", "/Root 1 0 R /Encrypt 9 0 R"); - Assert.Null(PdfRepair.Repair(Encoding.Latin1.GetBytes(text))); + 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))); + using var opened = PdfSharpConverter.Open(path, PdfDocumentOpenMode.Import); + 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 + { + using (var document = new PdfDocument()) + { + document.AddPage(); + document.SecuritySettings.UserPassword = "user-secret"; + document.SecuritySettings.OwnerPassword = "owner-secret"; + document.Save(path); + } + + Assert.Null(PdfRepair.Repair(File.ReadAllBytes(path))); + Assert.Throws(() => PdfSharpConverter.Open(path, PdfDocumentOpenMode.Import)); + } + finally + { + File.Delete(path); + } } } From e7fa8a7436aac2066d0cc5efacff416f77dbe0cd Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Fri, 6 Nov 2026 19:23:26 +0000 Subject: [PATCH 10/12] update --- src/Filee.Engines/Pdf/PdfRepair.cs | 171 +++++++++++++-------- src/Filee.Engines/Pdf/PdfSharpConverter.cs | 67 +++----- src/Filee.Engines/Pdf/PdfSheets.cs | 22 +-- 3 files changed, 135 insertions(+), 125 deletions(-) diff --git a/src/Filee.Engines/Pdf/PdfRepair.cs b/src/Filee.Engines/Pdf/PdfRepair.cs index 2cc58808f..62c9dbf10 100644 --- a/src/Filee.Engines/Pdf/PdfRepair.cs +++ b/src/Filee.Engines/Pdf/PdfRepair.cs @@ -4,51 +4,61 @@ // // 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, 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. - -using System.Text; -using System.Text.RegularExpressions; +// 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 file as text). - private const int MaxBytes = 256 * 1024 * 1024; + /// Above this size the repair is not tried (it holds the whole file in memory). + private const int MaxBytes = 128 * 1024 * 1024; - private static readonly Encoding Latin1 = Encoding.Latin1; + /// The most an object without a stream is searched for its "endobj". + private const int MaxObjectBytes = 4 * 1024 * 1024; - private static readonly Regex PrevRef = new(@"/Prev\s+(\d+)", RegexOptions.CultureInvariant); + /// 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) + public static byte[]? RepairFile(string path, CancellationToken cancellationToken = default) { + byte[] pdf; try { - return new FileInfo(path).Length is 0 or > MaxBytes ? null : Repair(File.ReadAllBytes(path)); + 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) + public static byte[]? Repair(byte[] pdf, CancellationToken cancellationToken = default) { if (pdf.Length is 0 or > MaxBytes) return null; - - var text = Latin1.GetString(pdf); // one char per byte, so a string index is a file offset - if (text.Contains("/Encrypt", StringComparison.Ordinal)) + 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; - foreach (var offset in ListedObjects(text)) + for (var k = 0; k < offsets.Length; k++) { - foreach (var at in SurplusClose(text, offset)) + 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)' '; @@ -59,121 +69,147 @@ internal static class PdfRepair } /// 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(string text) + private static HashSet ListedObjects(byte[] pdf) { var found = new HashSet(); var visited = new HashSet(); - var at = LastStartXref(text); + var at = LastStartXref(pdf); while (at is { } offset && visited.Add(offset)) - at = ReadTable(text, offset, found); + at = ReadTable(pdf, offset, found); return found; } - private static int? LastStartXref(string text) + private static int? LastStartXref(byte[] pdf) { - var keyword = text.LastIndexOf("startxref", StringComparison.Ordinal); + var keyword = pdf.AsSpan().LastIndexOf("startxref"u8); if (keyword < 0) return null; var pos = keyword + "startxref".Length; - return TryNumber(text, ref pos, out var offset) ? offset : null; + 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(string text, int offset, HashSet found) + private static int? ReadTable(byte[] pdf, int offset, HashSet found) { - if (offset < 0 || offset >= text.Length) + if (offset < 0 || offset >= pdf.Length) return null; var pos = offset; - SkipWhite(text, ref pos); - if (string.CompareOrdinal(text, pos, "xref", 0, 4) != 0) + 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(text, ref pos); - if (string.CompareOrdinal(text, pos, "trailer", 0, 7) == 0) + SkipWhite(pdf, ref pos); + if (pdf.AsSpan(pos).StartsWith("trailer"u8)) { pos += 7; break; } - if (!TryNumber(text, ref pos, out _) || !TryNumber(text, ref pos, out var count)) + if (!TryNumber(pdf, ref pos, out _) || !TryNumber(pdf, ref pos, out var count)) return null; for (var i = 0; i < count; i++) { - if (!TryNumber(text, ref pos, out var entryOffset) || !TryNumber(text, ref pos, out _)) + if (!TryNumber(pdf, ref pos, out var entryOffset) || !TryNumber(pdf, ref pos, out _)) return null; - SkipWhite(text, ref pos); - if (pos >= text.Length) + SkipWhite(pdf, ref pos); + if (pos >= pdf.Length) return null; - if (text[pos++] == 'n' && entryOffset > 0) + if (pdf[pos++] == (byte)'n' && entryOffset > 0) found.Add(entryOffset); } } - var end = text.IndexOf("startxref", pos, StringComparison.Ordinal); - var prev = PrevRef.Match(text, pos, (end < 0 ? text.Length : end) - pos); - return prev.Success && int.TryParse(prev.Groups[1].Value, out var previous) ? previous : null; + 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. Objects with a stream are left alone, and - /// so are strings, hex strings and comments. + /// 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(string text, int offset) + private static List SurplusClose(byte[] pdf, int offset, int bound) { var none = new List(); - var header = text.IndexOf("obj", offset, StringComparison.Ordinal); - if (header < 0 || header - offset > 24) + 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 < header; i++) // what sits at the offset must be "N G obj" - if (!char.IsAsciiDigit(text[i]) && text[i] != ' ') + 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 = header + 3; - var end = text.IndexOf("endobj", start, StringComparison.Ordinal); - if (end < 0 || text.AsSpan(start, end - start).IndexOf("stream", StringComparison.Ordinal) >= 0) + 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 (text[i]) + switch ((char)pdf[i]) { case '(': - i = SkipLiteralString(text, i, end); + i = SkipLiteralString(pdf, i, end); break; case '%': - while (i + 1 < end && text[i + 1] is not ('\r' or '\n')) + while (i + 1 < end && pdf[i + 1] is not ((byte)'\r' or (byte)'\n')) i++; break; - case '<' when i + 1 < end && text[i + 1] == '<': + case '<' when i + 1 < end && pdf[i + 1] == (byte)'<': open++; i++; break; case '<': // a hex string - var greater = text.IndexOf('>', i, end - i); + var greater = pdf.AsSpan(i, end - i).IndexOf((byte)'>'); if (greater < 0) return none; - i = greater; + i += greater; break; - case '>' when i + 1 < end && text[i + 1] == '>': + 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, before the end of the line. + private static bool IsStreamKeyword(byte[] pdf, int at, int start) + { + if (!pdf.AsSpan(at).StartsWith("stream"u8) || at + 6 >= pdf.Length || at <= start) + return false; + var before = pdf[at - 1]; + return (before is (byte)'>' or (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n' or (byte)'\f' or 0) + && pdf[at + 6] is (byte)'\r' or (byte)'\n'; + } + /// The index of the ")" that ends the literal string opened at (parentheses nest, "\" escapes). - private static int SkipLiteralString(string text, int open, int end) + private static int SkipLiteralString(byte[] pdf, int open, int end) { var depth = 1; for (var i = open + 1; i < end; i++) { - switch (text[i]) + switch ((char)pdf[i]) { case '\\': i++; @@ -190,19 +226,28 @@ private static int SkipLiteralString(string text, int open, int end) return end; } - private static void SkipWhite(string text, ref int pos) + private static void SkipWhite(byte[] pdf, ref int pos) { - while (pos < text.Length && text[pos] is ' ' or '\t' or '\r' or '\n' or '\f' or '\0') + 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(string text, ref int pos, out int value) + private static bool TryNumber(byte[] pdf, ref int pos, out int value) { - SkipWhite(text, ref pos); + SkipWhite(pdf, ref pos); var from = pos; - while (pos < text.Length && char.IsAsciiDigit(text[pos])) + while (pos < pdf.Length && char.IsAsciiDigit((char)pdf[pos])) pos++; value = 0; - return pos > from && pos - from <= 10 && int.TryParse(text.AsSpan(from, pos - from), out value); + 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 68ea3714d..57d8539af 100644 --- a/src/Filee.Engines/Pdf/PdfSharpConverter.cs +++ b/src/Filee.Engines/Pdf/PdfSharpConverter.cs @@ -55,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); } @@ -139,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); @@ -172,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(); @@ -249,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) @@ -315,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); @@ -358,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 OpenTolerant(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); @@ -388,9 +388,12 @@ internal static void CopyWritable(string source, string destination) /// /// 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) + private static PdfDocument OpenTolerant(string path, PdfDocumentOpenMode mode, CancellationToken cancellationToken, out byte[]? mended) { + mended = null; try { return PdfReader.Open(path, mode); @@ -398,11 +401,13 @@ private static PdfDocument OpenTolerant(string path, PdfDocumentOpenMode mode) catch (Exception ex) when (ex is not OperationCanceledException && !ex.Message.Contains("password", StringComparison.OrdinalIgnoreCase)) { - if (PdfRepair.RepairFile(path) is not { } repaired) + if (PdfRepair.RepairFile(path, cancellationToken) is not { } repaired) throw; try { - return PdfReader.Open(new MemoryStream(repaired), mode); + var document = PdfReader.Open(new MemoryStream(repaired), mode); + mended = repaired; + return document; } catch (Exception) { @@ -412,44 +417,16 @@ private static PdfDocument OpenTolerant(string path, PdfDocumentOpenMode mode) } } - /// - /// For code that needs a file path (PDFsharp's XPdfForm): a mended temporary copy of a PDF PDFsharp refuses, which the - /// caller deletes, or null when the file reads as it is or cannot be mended. - /// - internal static string? MendedCopy(string path) - { - try - { - using (PdfReader.Open(path, PdfDocumentOpenMode.Import)) - return null; - } - catch (Exception ex) when (ex is not OperationCanceledException - && !ex.Message.Contains("password", StringComparison.OrdinalIgnoreCase)) - { - if (PdfRepair.RepairFile(path) is not { } repaired) - return null; - try - { - using (PdfReader.Open(new MemoryStream(repaired), PdfDocumentOpenMode.Import)) - { - } - } - catch (Exception) - { - return null; - } - var copy = Path.Combine(Path.GetTempPath(), $"filee-mended-{Guid.NewGuid():N}.pdf"); - File.WriteAllBytes(copy, repaired); - return copy; - } - } - /// Opens a PDF; a password-protected one fails with the message the other PDF conversions give. - internal static PdfDocument Open(string path, PdfDocumentOpenMode mode) + 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); + return OpenTolerant(path, mode, cancellationToken, out mended); } catch (PdfReaderException ex) when (ex.Message.Contains("password", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Filee.Engines/Pdf/PdfSheets.cs b/src/Filee.Engines/Pdf/PdfSheets.cs index 52bb42813..3c91d9627 100644 --- a/src/Filee.Engines/Pdf/PdfSheets.cs +++ b/src/Filee.Engines/Pdf/PdfSheets.cs @@ -100,23 +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) { - // XPdfForm reads the file by its path, so a PDF PDFsharp refuses (issue #1175) is read from a mended copy. - var mended = PdfSharpConverter.MendedCopy(input); - try - { - WriteFrom(mended ?? input, output, options, progress, cancellationToken); - } - finally - { - if (mended is not null) - File.Delete(mended); - } - } - - private static void WriteFrom(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."); @@ -124,7 +112,7 @@ private static void WriteFrom(string input, string output, PdfOptions options, I 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) { From ea0115e495683c799ba9b81f1615418ffc9695c9 Mon Sep 17 00:00:00 2001 From: Marouane-elfadli Date: Fri, 6 Nov 2026 19:23:48 +0000 Subject: [PATCH 11/12] update --- tests/Filee.Engines.Tests/PdfRepairTests.cs | 107 ++++++++++++++++++-- 1 file changed, 100 insertions(+), 7 deletions(-) diff --git a/tests/Filee.Engines.Tests/PdfRepairTests.cs b/tests/Filee.Engines.Tests/PdfRepairTests.cs index 38484a169..ef58f9263 100644 --- a/tests/Filee.Engines.Tests/PdfRepairTests.cs +++ b/tests/Filee.Engines.Tests/PdfRepairTests.cs @@ -46,6 +46,16 @@ private static byte[] Pdf(string catalog, params string[] extra) 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] @@ -173,13 +183,7 @@ public void A_password_protected_pdf_is_reported_and_not_repaired() var path = TempPdf(); try { - using (var document = new PdfDocument()) - { - document.AddPage(); - document.SecuritySettings.UserPassword = "user-secret"; - document.SecuritySettings.OwnerPassword = "owner-secret"; - document.Save(path); - } + WritePasswordProtectedPdf(path); Assert.Null(PdfRepair.Repair(File.ReadAllBytes(path))); Assert.Throws(() => PdfSharpConverter.Open(path, PdfDocumentOpenMode.Import)); @@ -189,4 +193,93 @@ public void A_password_protected_pdf_is_reported_and_not_repaired() 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); + + 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))); + } + + [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)); + 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)) + { + Assert.NotNull(mended); + Assert.Equal(1, document.PageCount); + } + using (var document = PdfSharpConverter.Open(healthy, PdfDocumentOpenMode.Import, out var mended)) + { + Assert.Null(mended); + Assert.Equal(1, document.PageCount); + } + } + finally + { + File.Delete(damaged); + File.Delete(healthy); + } + } } From 17e78cd9db40e68d0911d4e47bd002a898e7b875 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Sat, 7 Nov 2026 05:07:02 +0900 Subject: [PATCH 12/12] fix(pdf): never count a stream's data when its keyword is written loosely The check for the "stream" keyword also wanted a line end right after it. In a file with "stream " or data on the same line, the data was counted, and a ">>" inside it was overwritten. Only the byte before the keyword is looked at now. Also pass the test's cancellation token to the calls that take one now (xUnit1051 stopped `dotnet format`) and drop the unrelated change to MergeImagesTests. Co-Authored-By: Claude Opus 5.5 --- src/Filee.Engines/Pdf/PdfRepair.cs | 11 +++--- tests/Filee.Core.Tests/MergeImagesTests.cs | 3 +- tests/Filee.Engines.Tests/PdfRepairTests.cs | 41 +++++++++++++-------- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/src/Filee.Engines/Pdf/PdfRepair.cs b/src/Filee.Engines/Pdf/PdfRepair.cs index 62c9dbf10..e5be83d98 100644 --- a/src/Filee.Engines/Pdf/PdfRepair.cs +++ b/src/Filee.Engines/Pdf/PdfRepair.cs @@ -193,14 +193,15 @@ private static List SurplusClose(byte[] pdf, int offset, int bound) return closes.Count <= open ? none : closes.GetRange(open, closes.Count - open); } - /// True when "stream" at is the keyword: after a ">>" or white space, before the end of the line. + /// + /// 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 + 6 >= pdf.Length || at <= start) + if (!pdf.AsSpan(at).StartsWith("stream"u8) || at <= start) return false; - var before = pdf[at - 1]; - return (before is (byte)'>' or (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n' or (byte)'\f' or 0) - && pdf[at + 6] is (byte)'\r' or (byte)'\n'; + 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). diff --git a/tests/Filee.Core.Tests/MergeImagesTests.cs b/tests/Filee.Core.Tests/MergeImagesTests.cs index 151cbed6d..e90d9689b 100644 --- a/tests/Filee.Core.Tests/MergeImagesTests.cs +++ b/tests/Filee.Core.Tests/MergeImagesTests.cs @@ -3,7 +3,6 @@ using System.Text.Json; using Filee.Core.Conversion; -using Filee.Core.Platform; using Filee.Core.Presets; using Filee.Core.Profiles; using Filee.Core.Settings; @@ -202,7 +201,7 @@ public async Task A_picture_the_joiner_cannot_read_becomes_a_plain_png_and_a_fir Assert.Equal("png", joiner.Format); Assert.Equal(2, joiner.Inputs.Count); Assert.Equal("png", joiner.Inputs[0].Format); - Assert.StartsWith(WorkFolders.Root, joiner.Inputs[0].Path); // in the job's work folder + Assert.StartsWith(JobQueue.DefaultWorkRoot, joiner.Inputs[0].Path); // in the job's work folder Assert.Equal((photo, "jpg"), joiner.Inputs[1]); var step = Assert.Single(engine.Presets); Assert.Equal((ResizeMode.None, ImageRotation.None, false), (step.Image.Resize, step.Image.Rotation, step.Image.JoinIntoOne)); diff --git a/tests/Filee.Engines.Tests/PdfRepairTests.cs b/tests/Filee.Engines.Tests/PdfRepairTests.cs index ef58f9263..b554f2068 100644 --- a/tests/Filee.Engines.Tests/PdfRepairTests.cs +++ b/tests/Filee.Engines.Tests/PdfRepairTests.cs @@ -62,7 +62,7 @@ private static void WritePasswordProtectedPdf(string path) public void Repair_overwrites_the_surplus_close_and_moves_nothing() { var damaged = DamagedPdf(); - var repaired = PdfRepair.Repair(damaged); + 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 @@ -73,7 +73,7 @@ public void Repair_overwrites_the_surplus_close_and_moves_nothing() [Fact] public void Repair_leaves_a_file_without_damage_as_it_is() { - Assert.Null(PdfRepair.Repair(Pdf(Catalog))); + Assert.Null(PdfRepair.Repair(Pdf(Catalog), TestContext.Current.CancellationToken)); } [Fact] @@ -81,9 +81,9 @@ 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))); + Assert.Null(PdfRepair.Repair(Pdf(Catalog, bookmark), TestContext.Current.CancellationToken)); - var repaired = PdfRepair.Repair(Pdf(DamagedCatalog, bookmark)); + var repaired = PdfRepair.Repair(Pdf(DamagedCatalog, bookmark), TestContext.Current.CancellationToken); Assert.NotNull(repaired); Assert.Contains("(Part A >> Part B)", Latin1(repaired), StringComparison.Ordinal); } @@ -95,7 +95,7 @@ public void Repair_only_looks_at_objects_the_xref_table_lists() 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)); + var repaired = PdfRepair.Repair(Pdf(DamagedCatalog, attached), TestContext.Current.CancellationToken); Assert.NotNull(repaired); Assert.Contains(inner, Latin1(repaired), StringComparison.Ordinal); @@ -105,7 +105,7 @@ public void Repair_only_looks_at_objects_the_xref_table_lists() public void Repair_gives_up_on_a_truncated_file() { var damaged = DamagedPdf(); - Assert.Null(PdfRepair.Repair(damaged[..(damaged.Length / 2)])); + Assert.Null(PdfRepair.Repair(damaged[..(damaged.Length / 2)], TestContext.Current.CancellationToken)); } [Fact] @@ -167,8 +167,8 @@ public void A_healthy_file_stays_as_it_is_even_with_an_attached_pdf_that_has_a_l document.Save(path); } - Assert.Null(PdfRepair.Repair(File.ReadAllBytes(path))); - using var opened = PdfSharpConverter.Open(path, PdfDocumentOpenMode.Import); + 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 @@ -185,8 +185,8 @@ public void A_password_protected_pdf_is_reported_and_not_repaired() { WritePasswordProtectedPdf(path); - Assert.Null(PdfRepair.Repair(File.ReadAllBytes(path))); - Assert.Throws(() => PdfSharpConverter.Open(path, PdfDocumentOpenMode.Import)); + Assert.Null(PdfRepair.Repair(File.ReadAllBytes(path), TestContext.Current.CancellationToken)); + Assert.Throws(() => PdfSharpConverter.Open(path, PdfDocumentOpenMode.Import, TestContext.Current.CancellationToken)); } finally { @@ -223,7 +223,7 @@ public void Repair_mends_a_damaged_bookmark_whose_title_has_the_word_stream_in_i 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); + var repaired = PdfRepair.Repair(damaged, TestContext.Current.CancellationToken); Assert.NotNull(repaired); Assert.Equal(damaged.Length, repaired.Length); @@ -241,7 +241,18 @@ public void Repair_leaves_an_object_with_a_stream_alone() 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))); + 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] @@ -251,7 +262,7 @@ public void Repair_can_be_cancelled_on_a_file_with_many_objects() 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)); + Assert.Null(PdfRepair.Repair(pdf, TestContext.Current.CancellationToken)); Assert.Throws(() => PdfRepair.Repair(pdf, new CancellationToken(canceled: true))); } @@ -265,12 +276,12 @@ public void Open_hands_back_the_mended_bytes_only_for_a_file_PDFsharp_refused() File.WriteAllBytes(damaged, DamagedPdf()); File.WriteAllBytes(healthy, Pdf(Catalog)); - using (var document = PdfSharpConverter.Open(damaged, PdfDocumentOpenMode.Import, out var mended)) + 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)) + using (var document = PdfSharpConverter.Open(healthy, PdfDocumentOpenMode.Import, out var mended, TestContext.Current.CancellationToken)) { Assert.Null(mended); Assert.Equal(1, document.PageCount);