From aeabf0bb4f67285e1d4f1702c653348907c5e9f6 Mon Sep 17 00:00:00 2001 From: Jason Finch Date: Tue, 28 Jul 2026 08:23:52 +1000 Subject: [PATCH] Support reading PDF 2.0 files GetPdfFileVersion gated the major version digit with `major < '2'`, so a %PDF-2.0 header parsed to 0. PdfReader.Open treats 0 as fatal, which made every PDF 2.0 file fail with "The file is not a valid PDF document." Accept major version 1 or 2. The version scan was duplicated verbatim in a raw-encoding attempt and an ASCII fallback, so extract it into ScanFileVersion rather than patching the same condition twice, and guard the header indexing explicitly instead of relying on a caught IndexOutOfRangeException. PdfDocument.Version rejected anything outside 12..17, so setting 20 threw; permit it, and refresh the stale InvalidVersionNumber message. PdfWriter needs no change, as it already formats the header from version/10 and version%10. Fixes #490 --- PdfSharpCore.Test/Assets/Pdf20.pdf | Bin 0 -> 8591 bytes PdfSharpCore.Test/IO/PdfVersionTests.cs | 65 ++++++++++++++++++++++++ PdfSharpCore/Pdf.IO/PdfReader.cs | 44 ++++++++-------- PdfSharpCore/Pdf/PdfDocument.cs | 3 +- PdfSharpCore/root/PSSR.cs | 2 +- 5 files changed, 89 insertions(+), 25 deletions(-) create mode 100644 PdfSharpCore.Test/Assets/Pdf20.pdf create mode 100644 PdfSharpCore.Test/IO/PdfVersionTests.cs diff --git a/PdfSharpCore.Test/Assets/Pdf20.pdf b/PdfSharpCore.Test/Assets/Pdf20.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d26da3318f8c866f1b59801f4990f67ee19c175c GIT binary patch literal 8591 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 JbxrlK{{^6-Xn+6! literal 0 HcmV?d00001 diff --git a/PdfSharpCore.Test/IO/PdfVersionTests.cs b/PdfSharpCore.Test/IO/PdfVersionTests.cs new file mode 100644 index 00000000..5d7ef28f --- /dev/null +++ b/PdfSharpCore.Test/IO/PdfVersionTests.cs @@ -0,0 +1,65 @@ +using FluentAssertions; +using PdfSharpCore.Pdf; +using PdfSharpCore.Pdf.IO; +using PdfSharpCore.Test.Helpers; +using System.IO; +using System.Text; +using Xunit; + +namespace PdfSharpCore.Test.IO +{ + public class PdfVersionTests + { + [Theory] + [InlineData("%PDF-1.0\n", 10)] + [InlineData("%PDF-1.4\n", 14)] + [InlineData("%PDF-1.7\n", 17)] + [InlineData("%PDF-2.0\n", 20)] + // Acrobat also accepts a PostScript style header with the PDF version embedded. + [InlineData("%!PS-Adobe-3.0 PDF-2.0\n", 20)] + public void TestPdfFile_returnsTheVersionOfTheHeader(string header, int expected) + { + Pdf.IO.PdfReader.TestPdfFile(Encoding.ASCII.GetBytes(header)).Should().Be(expected); + } + + [Theory] + [InlineData("Definitely not a PDF")] + [InlineData("%PDF-0.9\n")] + [InlineData("%PDF-1.A\n")] + [InlineData("%PDF\n")] + public void TestPdfFile_returnsZeroForANonPdfHeader(string header) + { + Pdf.IO.PdfReader.TestPdfFile(Encoding.ASCII.GetBytes(header)).Should().Be(0); + } + + [Fact] + public void Should_beAbleToReadAPdf20Document() + { + using var fs = File.OpenRead(PathHelper.GetInstance().GetAssetPath("Pdf20.pdf")); + var inputDocument = Pdf.IO.PdfReader.Open(fs, PdfDocumentOpenMode.Import); + + inputDocument.Should().NotBeNull(); + inputDocument.Version.Should().Be(20); + inputDocument.PageCount.Should().BeGreaterThan(0); + } + + [Fact] + public void Should_preserveThePdf20HeaderWhenSavingADocument() + { + using var fs = File.OpenRead(PathHelper.GetInstance().GetAssetPath("Pdf20.pdf")); + var inputDocument = Pdf.IO.PdfReader.Open(fs, PdfDocumentOpenMode.Modify); + + using var ms = new MemoryStream(); + inputDocument.Save(ms, false); + + Encoding.ASCII.GetString(ms.ToArray(), 0, 8).Should().Be("%PDF-2.0"); + } + + [Fact] + public void Version_acceptsPdf20() + { + var document = new PdfDocument { Version = 20 }; + document.Version.Should().Be(20); + } + } +} diff --git a/PdfSharpCore/Pdf.IO/PdfReader.cs b/PdfSharpCore/Pdf.IO/PdfReader.cs index 2cdb0b90..8e41f7cd 100644 --- a/PdfSharpCore/Pdf.IO/PdfReader.cs +++ b/PdfSharpCore/Pdf.IO/PdfReader.cs @@ -153,19 +153,37 @@ public static int TestPdfFile(byte[] data) /// /// internal static int GetPdfFileVersion(byte[] bytes) + { + int version = ScanFileVersion(PdfEncoders.RawEncoding, bytes); + + // If it doesn't work with the specified encoding the file might be incorrectly encoded as ASCII. + if (version == 0) + version = ScanFileVersion(System.Text.Encoding.ASCII, bytes); + + return version; + } + + /// + /// Scans the file header for «%PDF-x.y» using the specified encoding and returns the version + /// as an integer (e.g. 14 for PDF 1.4, 20 for PDF 2.0), or 0 if no version was found. + /// + static int ScanFileVersion(System.Text.Encoding encoding, byte[] bytes) { try { // Acrobat accepts headers like «%!PS-Adobe-N.n PDF-M.m»... - string header = PdfEncoders.RawEncoding.GetString(bytes, 0, bytes.Length); // Encoding.ASCII.GetString(bytes); + string header = encoding.GetString(bytes, 0, bytes.Length); + if (header.Length == 0) + return 0; if (header[0] == '%' || header.IndexOf("%PDF", StringComparison.Ordinal) >= 0) { int ich = header.IndexOf("PDF-", StringComparison.Ordinal); - if (ich > 0 && header[ich + 5] == '.') + if (ich > 0 && ich + 6 < header.Length && header[ich + 5] == '.') { char major = header[ich + 4]; char minor = header[ich + 6]; - if (major >= '1' && major < '2' && minor >= '0' && minor <= '9') + // PDF 1.0 to 1.7 and PDF 2.0 are the versions defined so far. + if (major >= '1' && major <= '2' && minor >= '0' && minor <= '9') return (major - '0') * 10 + (minor - '0'); } } @@ -174,26 +192,6 @@ internal static int GetPdfFileVersion(byte[] bytes) catch { } - // If it doesn't work with the specified encoding ... - try - { - // The file might be incorrectly encoded as ASCII - string header = System.Text.Encoding.ASCII.GetString(bytes); - if (header[0] == '%' || header.IndexOf("%PDF", StringComparison.Ordinal) >= 0) - { - int ich = header.IndexOf("PDF-", StringComparison.Ordinal); - if (ich > 0 && header[ich + 5] == '.') - { - char major = header[ich + 4]; - char minor = header[ich + 6]; - if (major >= '1' && major < '2' && minor >= '0' && minor <= '9') - return (major - '0') * 10 + (minor - '0'); - } - } - } - // ReSharper disable once EmptyGeneralCatchClause - catch { } - return 0; } diff --git a/PdfSharpCore/Pdf/PdfDocument.cs b/PdfSharpCore/Pdf/PdfDocument.cs index 674ef4b0..fb7e6f68 100644 --- a/PdfSharpCore/Pdf/PdfDocument.cs +++ b/PdfSharpCore/Pdf/PdfDocument.cs @@ -483,6 +483,7 @@ internal bool EarlyWrite /// /// Gets or sets the PDF version number. Return value 14 e.g. means PDF 1.4 / Acrobat 5 etc. + /// Return value 20 means PDF 2.0. /// public int Version { @@ -491,7 +492,7 @@ public int Version { if (!CanModify) throw new InvalidOperationException(PSSR.CannotModify); - if (value < 12 || value > 17) // TODO not really implemented + if ((value < 12 || value > 17) && value != 20) // TODO not really implemented throw new ArgumentException(PSSR.InvalidVersionNumber, "value"); _version = value; } diff --git a/PdfSharpCore/root/PSSR.cs b/PdfSharpCore/root/PSSR.cs index f047cbfd..62c7f376 100644 --- a/PdfSharpCore/root/PSSR.cs +++ b/PdfSharpCore/root/PSSR.cs @@ -217,7 +217,7 @@ public static string InvalidPdf public static string InvalidVersionNumber { - get { return "Invalid version number. Valid values are 12, 13, and 14."; } + get { return "Invalid version number. Valid values are 12 to 17 and 20."; } } public static string CannotHandleXRefStreams