From eaba447e4af796b84417f2fa48a18b07da85dff4 Mon Sep 17 00:00:00 2001 From: Andrey Polischuk Date: Thu, 19 Jun 2025 17:21:37 +0300 Subject: [PATCH] Simple implementation of QPdfJob for maximum compression of pdf files --- .gitignore | 1 + global.json | 6 ++ .../NullableIntToStringConverter.cs | 18 +++++ .../Converters/QPdfCompressStreamConverter.cs | 24 ++++++ src/QPdfSharp/Enums/QPdfCompressStream.cs | 9 +++ src/QPdfSharp/Options/QPdfJobOptions.cs | 37 +++++++++ src/QPdfSharp/Options/QPdfWriteOptions.cs | 2 +- src/QPdfSharp/QPdfJob.cs | 79 +++++++++++++++++++ src/QPdfSharp/QPdfSharp.csproj | 1 + tests/QPdfSharp.Tests/QPdfJobTests.cs | 55 +++++++++++++ tests/QPdfSharp.Tests/QPdfReadingTests.cs | 1 - tests/QPdfSharp.Tests/QPdfSharp.Tests.csproj | 4 + tests/QPdfSharp.Tests/TestAssets.cs | 2 +- 13 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 global.json create mode 100644 src/QPdfSharp/Converters/NullableIntToStringConverter.cs create mode 100644 src/QPdfSharp/Converters/QPdfCompressStreamConverter.cs create mode 100644 src/QPdfSharp/Enums/QPdfCompressStream.cs create mode 100644 src/QPdfSharp/Options/QPdfJobOptions.cs create mode 100644 src/QPdfSharp/QPdfJob.cs create mode 100644 tests/QPdfSharp.Tests/QPdfJobTests.cs diff --git a/.gitignore b/.gitignore index 1ca3cb8..aca9072 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ _UpgradeReport_Files/ Thumbs.db Desktop.ini .DS_Store +/deploy.cmd diff --git a/global.json b/global.json new file mode 100644 index 0000000..952b4c4 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.406", + "rollForward": "latestFeature" + } +} diff --git a/src/QPdfSharp/Converters/NullableIntToStringConverter.cs b/src/QPdfSharp/Converters/NullableIntToStringConverter.cs new file mode 100644 index 0000000..c152692 --- /dev/null +++ b/src/QPdfSharp/Converters/NullableIntToStringConverter.cs @@ -0,0 +1,18 @@ +// Copyright © Stephen (Sven) Vernyi and Contributors. Licensed under the MIT License (MIT). See License.md in the repository root for more information. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace QPdfSharp.Converters; + +public class NullableIntToStringConverter : JsonConverter +{ + public override int? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotImplementedException(); + + public override void Write(Utf8JsonWriter writer, int? value, JsonSerializerOptions options) + { + if (value.HasValue) + writer.WriteStringValue(value.Value.ToString()); + } +} diff --git a/src/QPdfSharp/Converters/QPdfCompressStreamConverter.cs b/src/QPdfSharp/Converters/QPdfCompressStreamConverter.cs new file mode 100644 index 0000000..8229e2f --- /dev/null +++ b/src/QPdfSharp/Converters/QPdfCompressStreamConverter.cs @@ -0,0 +1,24 @@ +// Copyright © Stephen (Sven) Vernyi and Contributors. Licensed under the MIT License (MIT). See License.md in the repository root for more information. + +using System.Text.Json; +using System.Text.Json.Serialization; +using QPdfSharp.Enums; + +namespace QPdfSharp.Converters; + +public class QPdfCompressStreamConverter : JsonConverter +{ + public override QPdfCompressStream Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => throw new NotImplementedException(); + + public override void Write(Utf8JsonWriter writer, QPdfCompressStream value, JsonSerializerOptions options) + { + var result = value switch + { + QPdfCompressStream.No => "n", + QPdfCompressStream.Yes => "y", + _ => throw new JsonException($"Unknown value enum: {value}") + }; + writer.WriteStringValue(result); + } +} diff --git a/src/QPdfSharp/Enums/QPdfCompressStream.cs b/src/QPdfSharp/Enums/QPdfCompressStream.cs new file mode 100644 index 0000000..aacb133 --- /dev/null +++ b/src/QPdfSharp/Enums/QPdfCompressStream.cs @@ -0,0 +1,9 @@ +// Copyright © Stephen (Sven) Vernyi and Contributors. Licensed under the MIT License (MIT). See License.md in the repository root for more information. + +namespace QPdfSharp.Enums; + +public enum QPdfCompressStream : uint +{ + No = 0, + Yes +} diff --git a/src/QPdfSharp/Options/QPdfJobOptions.cs b/src/QPdfSharp/Options/QPdfJobOptions.cs new file mode 100644 index 0000000..df9aae9 --- /dev/null +++ b/src/QPdfSharp/Options/QPdfJobOptions.cs @@ -0,0 +1,37 @@ +// Copyright © Stephen (Sven) Vernyi and Contributors. Licensed under the MIT License (MIT). See License.md in the repository root for more information. + +using QPdfSharp.Converters; +using System.Text.Json.Serialization; +using QPdfSharp.Enums; + +namespace QPdfSharp.Options; + +public sealed class QPdfJobOptions +{ + // qpdf.exe in.pdf out.pdf --compress-streams=y --recompress-flate --compression-level=9 --object-streams=generate --decode-level=generalized --optimize-images + public string? InputFile { get; set; } + public string? OutputFile { get; set; } + + [JsonConverter(typeof(QPdfCompressStreamConverter))] + public QPdfCompressStream? CompressStreams { get; set; } + + [JsonConverter(typeof(NullableIntToStringConverter))] + public int? CompressionLevel { get; set; } + public string? RecompressFlate { get; set; } + public string? OptimizeImages { get; set; } + //public bool? NormalizeContent { get; set; } + public QPdfObjectStream? ObjectStreams { get; set; } + public QPdfStreamDecodeLevel? DecodeLevel { get; set; } + + public static QPdfJobOptions CreateWithMaxCompression(string inputFile, string outputFile) + => new() { + InputFile = inputFile, + OutputFile = outputFile, + CompressStreams = QPdfCompressStream.Yes, + CompressionLevel = 9, + RecompressFlate = "", + OptimizeImages = "", + ObjectStreams = QPdfObjectStream.Generate, + DecodeLevel = QPdfStreamDecodeLevel.Generalized + }; +} diff --git a/src/QPdfSharp/Options/QPdfWriteOptions.cs b/src/QPdfSharp/Options/QPdfWriteOptions.cs index 5a24454..66364ea 100644 --- a/src/QPdfSharp/Options/QPdfWriteOptions.cs +++ b/src/QPdfSharp/Options/QPdfWriteOptions.cs @@ -1,4 +1,4 @@ -// Copyright © Stephen (Sven) Vernyi and Contributors. Licensed under the MIT License (MIT). See License.md in the repository root for more information. +// Copyright © Stephen (Sven) Vernyi and Contributors. Licensed under the MIT License (MIT). See License.md in the repository root for more information. using QPdfSharp.Enums; diff --git a/src/QPdfSharp/QPdfJob.cs b/src/QPdfSharp/QPdfJob.cs new file mode 100644 index 0000000..0aa1401 --- /dev/null +++ b/src/QPdfSharp/QPdfJob.cs @@ -0,0 +1,79 @@ +// Copyright © Stephen (Sven) Vernyi and Contributors. Licensed under the MIT License (MIT). See License.md in the repository root for more information. + +using System.Text; +using System.Text.Json.Serialization; +using System.Text.Json; +using QPdfSharp.Extensions; +using QPdfSharp.Interop; +using QPdfSharp.Options; + +namespace QPdfSharp; + +public unsafe class QPdfJob : IDisposable +{ + public static readonly string Version = new(QPdfInterop.qpdf_get_qpdf_version()); + + private readonly QPdfJobHandle* _qPdfJob = QPdfInterop.qpdfjob_init(); + + private QPdfLoggerHandle* _qPdfLogger = null; + + private readonly StringBuilder _errorBuilder = new(); + + public QPdfJob(string filePath, string outFilePath) + => InitializeQPdfJob(QPdfJobOptions.CreateWithMaxCompression(filePath, outFilePath)); + + ~QPdfJob() => ReleaseUnmanagedResources(); + + public void Run() + => CheckError(QPdfInterop.qpdfjob_run(_qPdfJob)); + + private void CheckError(int? errorCode = null) + { + if (errorCode == 0) + return; + + var errorMessage = _errorBuilder.ToString(); + if (string.IsNullOrWhiteSpace(errorMessage)) + return; + + _errorBuilder.Clear(); + + throw new QPdfException(errorMessage); + } + + public void Dispose() + { + ReleaseUnmanagedResources(); + GC.SuppressFinalize(this); + } + + private void ReleaseUnmanagedResources() + { + fixed (QPdfLoggerHandle** logHandle = &_qPdfLogger) + QPdfInterop.qpdflogger_cleanup(logHandle); + + fixed (QPdfJobHandle** jobHandle = &_qPdfJob) + QPdfInterop.qpdfjob_cleanup(jobHandle); + } + + private void InitializeQPdfJob(QPdfJobOptions? options) + { + QPdfLogFunction writeError = (data, len, _) => { + _errorBuilder.Append(new string(data, 0, (int)len)); + return 0; + }; + + _qPdfLogger = QPdfInterop.qpdflogger_create(); + QPdfInterop.qpdflogger_set_error(_qPdfLogger, QPdfLogDestination.qpdf_log_dest_custom, writeError, null); + QPdfInterop.qpdfjob_set_logger(_qPdfJob, _qPdfLogger); + + var serializerOptions = new JsonSerializerOptions { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) } + }; + var json = JsonSerializer.Serialize(options, serializerOptions); + fixed (sbyte* jsonBytes = json.ToSByte()) + CheckError(QPdfInterop.qpdfjob_initialize_from_json(_qPdfJob, jsonBytes)); + } +} diff --git a/src/QPdfSharp/QPdfSharp.csproj b/src/QPdfSharp/QPdfSharp.csproj index 20e4487..4504f36 100644 --- a/src/QPdfSharp/QPdfSharp.csproj +++ b/src/QPdfSharp/QPdfSharp.csproj @@ -10,6 +10,7 @@ + diff --git a/tests/QPdfSharp.Tests/QPdfJobTests.cs b/tests/QPdfSharp.Tests/QPdfJobTests.cs new file mode 100644 index 0000000..c2b7df6 --- /dev/null +++ b/tests/QPdfSharp.Tests/QPdfJobTests.cs @@ -0,0 +1,55 @@ +// Copyright © Stephen (Sven) Vernyi and Contributors. Licensed under the MIT License (MIT). See License.md in the repository root for more information. + +namespace QPdfSharp.Tests; + +public class QPdfJobTests +{ + [Fact] + public void Can_read_qpdf_version() + { + // Arrange + // Act + var version = QPdfJob.Version; + + // Assert + Version.TryParse(version, out _).Should().BeTrue(); + } + + [Theory] + [InlineData(TestAssets.Grug)] + public void Can_compress_pdf_from_file_path(string filePath) + { + // Arrange + var outputFileName = "small.pdf"; + + // Act + var run = () => { + using var qPdfJob = new QPdfJob(filePath, outputFileName); + qPdfJob.Run(); + }; + + // Assert + run.Should().NotThrow(); + File.Exists(outputFileName).Should().BeTrue(); + new FileInfo(outputFileName).Length.Should().BeLessThan(new FileInfo(filePath).Length); + File.Delete(outputFileName); + } + + [Theory] + [InlineData("abc.pdf")] + [InlineData("Assets/abc.pdf")] + public void Should_throw_exception_if_file_not_found(string filePath) + { + // Arrange + var outputFileName = "small.pdf"; + + // Act + var run = () => { + using var qPdfJob = new QPdfJob(filePath, outputFileName); + qPdfJob.Run(); + }; + + // Assert + run.Should().Throw($"qpdfjob json: open {filePath}: No such file or directory"); + } +} diff --git a/tests/QPdfSharp.Tests/QPdfReadingTests.cs b/tests/QPdfSharp.Tests/QPdfReadingTests.cs index abd2c8a..8eb8609 100644 --- a/tests/QPdfSharp.Tests/QPdfReadingTests.cs +++ b/tests/QPdfSharp.Tests/QPdfReadingTests.cs @@ -46,4 +46,3 @@ public async Task Can_read_pdf_from_memory(string filePath) createPdfFromBytes.Should().NotThrow(); } } - diff --git a/tests/QPdfSharp.Tests/QPdfSharp.Tests.csproj b/tests/QPdfSharp.Tests/QPdfSharp.Tests.csproj index 22d9d75..c3810b2 100644 --- a/tests/QPdfSharp.Tests/QPdfSharp.Tests.csproj +++ b/tests/QPdfSharp.Tests/QPdfSharp.Tests.csproj @@ -1,5 +1,9 @@ + + net8.0 + + diff --git a/tests/QPdfSharp.Tests/TestAssets.cs b/tests/QPdfSharp.Tests/TestAssets.cs index d7fe6be..9df889d 100644 --- a/tests/QPdfSharp.Tests/TestAssets.cs +++ b/tests/QPdfSharp.Tests/TestAssets.cs @@ -1,4 +1,4 @@ -// Copyright © Stephen (Sven) Vernyi and Contributors. Licensed under the MIT License (MIT). See License.md in the repository root for more information. +// Copyright © Stephen (Sven) Vernyi and Contributors. Licensed under the MIT License (MIT). See License.md in the repository root for more information. namespace QPdfSharp.Tests;