From 0ba58617ce2cbd5f65f74188dd161d581323aef4 Mon Sep 17 00:00:00 2001 From: Ben Olden-Cooligan Date: Wed, 13 Dec 2023 21:46:45 -0800 Subject: [PATCH] Escl: Quality fixes --- NAPS2.Escl.Server/EsclApiController.cs | 7 +++- NAPS2.Escl.Server/SettingsParser.cs | 1 + NAPS2.Escl/Client/CapabilitiesParser.cs | 18 +++++---- NAPS2.Escl/Client/EsclClient.cs | 9 ++++- NAPS2.Escl/EsclCapabilities.cs | 1 + NAPS2.Escl/EsclRange.cs | 3 ++ NAPS2.Escl/EsclScanSettings.cs | 2 + NAPS2.Escl/EsclSettingProfile.cs | 4 +- NAPS2.Escl/ResolutionRange.cs | 3 -- NAPS2.Images/ImageExtensions.cs | 40 ++++++++++++++++++- NAPS2.Images/NAPS2.Images.csproj | 1 + .../Remoting/ScanServerIntegrationTests.cs | 12 +++++- NAPS2.Sdk/Pdf/PdfExporter.cs | 7 ++-- NAPS2.Sdk/Remoting/Server/ScanJob.cs | 18 +++++---- NAPS2.Sdk/Remoting/Server/ScanServer.cs | 16 +------- .../Scan/Internal/Escl/EsclScanDriver.cs | 17 +++++--- NAPS2.Sdk/Scan/ScanOptions.cs | 4 +- 17 files changed, 113 insertions(+), 50 deletions(-) create mode 100644 NAPS2.Escl/EsclRange.cs delete mode 100644 NAPS2.Escl/ResolutionRange.cs diff --git a/NAPS2.Escl.Server/EsclApiController.cs b/NAPS2.Escl.Server/EsclApiController.cs index a5901eeea6..a6bf35365f 100644 --- a/NAPS2.Escl.Server/EsclApiController.cs +++ b/NAPS2.Escl.Server/EsclApiController.cs @@ -42,7 +42,12 @@ public async Task GetScannerCapabilities() new XElement(ScanNs + "PlatenInputCaps", GetCommonInputCaps())), new XElement(ScanNs + "Adf", new XElement(ScanNs + "AdfSimplexInputCaps", GetCommonInputCaps()), - new XElement(ScanNs + "AdfDuplexInputCaps", GetCommonInputCaps())))); + new XElement(ScanNs + "AdfDuplexInputCaps", GetCommonInputCaps())), + new XElement(ScanNs + "CompressionFactorSupport", + new XElement(ScanNs + "Min", 0), + new XElement(ScanNs + "Max", 100), + new XElement(ScanNs + "Normal", 75), + new XElement(ScanNs + "Step", 1)))); Response.ContentType = "text/xml"; using var writer = new StreamWriter(HttpContext.OpenResponseStream()); await writer.WriteAsync(doc); diff --git a/NAPS2.Escl.Server/SettingsParser.cs b/NAPS2.Escl.Server/SettingsParser.cs index 2b91e997f2..8d2a8a249b 100644 --- a/NAPS2.Escl.Server/SettingsParser.cs +++ b/NAPS2.Escl.Server/SettingsParser.cs @@ -29,6 +29,7 @@ public static EsclScanSettings Parse(XDocument doc) Height = ParseHelper.MaybeParseInt(scanRegion?.Element(PwgNs + "Height")) ?? 0, XOffset = ParseHelper.MaybeParseInt(scanRegion?.Element(PwgNs + "XOffset")) ?? 0, YOffset = ParseHelper.MaybeParseInt(scanRegion?.Element(PwgNs + "YOffset")) ?? 0, + CompressionFactor = ParseHelper.MaybeParseInt(root.Element(ScanNs + "CompressionFactor")) }; } } \ No newline at end of file diff --git a/NAPS2.Escl/Client/CapabilitiesParser.cs b/NAPS2.Escl/Client/CapabilitiesParser.cs index ecbe568173..ec9ee342c5 100644 --- a/NAPS2.Escl/Client/CapabilitiesParser.cs +++ b/NAPS2.Escl/Client/CapabilitiesParser.cs @@ -37,7 +37,8 @@ public static EsclCapabilities Parse(XDocument doc) Naps2Extensions = root.Element(ScanNs + "Naps2Extensions")?.Value, PlatenCaps = ParseInputCaps(platenCapsEl, settingProfiles), AdfSimplexCaps = ParseInputCaps(adfSimplexCapsEl, settingProfiles), - AdfDuplexCaps = ParseInputCaps(adfDuplexCapsEl, settingProfiles) + AdfDuplexCaps = ParseInputCaps(adfDuplexCapsEl, settingProfiles), + CompressionFactorSupport = ParseRange(root.Element(ScanNs + "CompressionFactorSupport")) }; } @@ -60,9 +61,9 @@ private static EsclSettingProfile ParseSettingProfile(XElement element, .Select(x => x.Value).ToList() ?? new List(), DiscreteResolutions = ParseDiscreteResolutions(element.Element(ScanNs + "SupportedResolutions") ?.Element(ScanNs + "DiscreteResolutions")?.Elements(ScanNs + "DiscreteResolution")), - XResolutionRange = ParseResolutionRange(element.Element(ScanNs + "SupportedResolutions") + XResolutionRange = ParseRange(element.Element(ScanNs + "SupportedResolutions") ?.Element(ScanNs + "XResolutionRange")), - YResolutionRange = ParseResolutionRange(element.Element(ScanNs + "SupportedResolutions") + YResolutionRange = ParseRange(element.Element(ScanNs + "SupportedResolutions") ?.Element(ScanNs + "YResolutionRange")), }; if (profile.Name != null) @@ -72,16 +73,17 @@ private static EsclSettingProfile ParseSettingProfile(XElement element, return profile; } - private static ResolutionRange? ParseResolutionRange(XElement? element) + private static EsclRange? ParseRange(XElement? element) { if (element == null) return null; - var min = element.Element(ScanNs + "Min"); - var max = element.Element(ScanNs + "Max"); - var normal = element.Element(ScanNs + "Max"); + var min = ParseHelper.MaybeParseInt(element.Element(ScanNs + "Min")); + var max = ParseHelper.MaybeParseInt(element.Element(ScanNs + "Max")); + var normal = ParseHelper.MaybeParseInt(element.Element(ScanNs + "Normal")); + var step = ParseHelper.MaybeParseInt(element.Element(ScanNs + "Step")); if (min != null && max != null && normal != null) { - return new ResolutionRange(int.Parse(min.Value), int.Parse(max.Value), int.Parse(normal.Value)); + return new EsclRange(min.Value, max.Value, normal.Value, step ?? 1); } return null; } diff --git a/NAPS2.Escl/Client/EsclClient.cs b/NAPS2.Escl/Client/EsclClient.cs index 859957c0de..e2ce9fb1e4 100644 --- a/NAPS2.Escl/Client/EsclClient.cs +++ b/NAPS2.Escl/Client/EsclClient.cs @@ -86,6 +86,7 @@ public async Task CreateScanJob(EsclScanSettings settings) // new XElement(ScanNs + "Brightness", settings.Brightness), // new XElement(ScanNs + "Contrast", settings.Contrast), // new XElement(ScanNs + "Threshold", settings.Threshold), + OptionalElement(ScanNs + "CompressionFactor", settings.CompressionFactor), new XElement(PwgNs + "DocumentFormat", settings.DocumentFormat))); var content = new StringContent(doc, Encoding.UTF8, "text/xml"); var url = GetUrl($"/{_service.RootUrl}/ScanJobs"); @@ -100,6 +101,12 @@ public async Task CreateScanJob(EsclScanSettings settings) }; } + private XElement? OptionalElement(XName elementName, int? value) + { + if (value == null) return null; + return new XElement(elementName, value); + } + public async Task NextDocument(EsclJob job, Action? pageProgress = null) { var progressCts = new CancellationTokenSource(); @@ -155,7 +162,7 @@ public async Task CreateScanJob(EsclScanSettings settings) public async Task ErrorDetails(EsclJob job) { - var url = GetUrl($"{job.UriPath}/ErrorDetails");; + var url = GetUrl($"{job.UriPath}/ErrorDetails"); Logger.LogDebug("ESCL GET {Url}", url); var response = await HttpClient.GetAsync(url); response.EnsureSuccessStatusCode(); diff --git a/NAPS2.Escl/EsclCapabilities.cs b/NAPS2.Escl/EsclCapabilities.cs index 61b94a380c..767e245629 100644 --- a/NAPS2.Escl/EsclCapabilities.cs +++ b/NAPS2.Escl/EsclCapabilities.cs @@ -15,4 +15,5 @@ public class EsclCapabilities public EsclInputCaps? PlatenCaps { get; init; } public EsclInputCaps? AdfSimplexCaps { get; init; } public EsclInputCaps? AdfDuplexCaps { get; init; } + public EsclRange? CompressionFactorSupport { get; init; } } \ No newline at end of file diff --git a/NAPS2.Escl/EsclRange.cs b/NAPS2.Escl/EsclRange.cs new file mode 100644 index 0000000000..f07988dd3e --- /dev/null +++ b/NAPS2.Escl/EsclRange.cs @@ -0,0 +1,3 @@ +namespace NAPS2.Escl; + +public record EsclRange(int Min, int Max, int Normal, int Step); \ No newline at end of file diff --git a/NAPS2.Escl/EsclScanSettings.cs b/NAPS2.Escl/EsclScanSettings.cs index d969b4410f..909c567908 100644 --- a/NAPS2.Escl/EsclScanSettings.cs +++ b/NAPS2.Escl/EsclScanSettings.cs @@ -27,4 +27,6 @@ public class EsclScanSettings public int Contrast { get; init; } public int Threshold { get; init; } + + public int? CompressionFactor { get; set; } } \ No newline at end of file diff --git a/NAPS2.Escl/EsclSettingProfile.cs b/NAPS2.Escl/EsclSettingProfile.cs index 7c3953e8d6..7247d19ffa 100644 --- a/NAPS2.Escl/EsclSettingProfile.cs +++ b/NAPS2.Escl/EsclSettingProfile.cs @@ -9,6 +9,6 @@ public class EsclSettingProfile public List DocumentFormats { get; init; } = new(); public List DocumentFormatsExt { get; init; } = new(); public List DiscreteResolutions { get; init; } = new(); - public ResolutionRange? XResolutionRange { get; init; } - public ResolutionRange? YResolutionRange { get; init; } + public EsclRange? XResolutionRange { get; init; } + public EsclRange? YResolutionRange { get; init; } } \ No newline at end of file diff --git a/NAPS2.Escl/ResolutionRange.cs b/NAPS2.Escl/ResolutionRange.cs deleted file mode 100644 index 036b4d4424..0000000000 --- a/NAPS2.Escl/ResolutionRange.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace NAPS2.Escl; - -public record ResolutionRange(int Min, int Max, int Normal); \ No newline at end of file diff --git a/NAPS2.Images/ImageExtensions.cs b/NAPS2.Images/ImageExtensions.cs index 76b76c8051..ac2ff6328f 100644 --- a/NAPS2.Images/ImageExtensions.cs +++ b/NAPS2.Images/ImageExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using NAPS2.Images.Bitwise; namespace NAPS2.Images; @@ -9,6 +10,25 @@ public static IMemoryImage Render(this IRenderableImage image) return image.ImageContext.Render(image); } + /// + /// Checks if we can copy the source JPEG directly rather than re-encoding and suffering JPEG degradation. + /// + /// + /// + /// + internal static bool IsUntransformedJpegFile(this IRenderableImage image, + [MaybeNullWhen(false)] out string jpegPath) + { + if (image is { Storage: ImageFileStorage fileStorage, TransformState.IsEmpty: true } && + ImageContext.GetFileFormatFromExtension(fileStorage.FullPath) == ImageFileFormat.Jpeg) + { + jpegPath = fileStorage.FullPath; + return true; + } + jpegPath = null; + return false; + } + /// /// Saves the image to the given file path. If the file format is unspecified, it will be inferred from the /// file extension if possible. @@ -20,7 +40,15 @@ public static IMemoryImage Render(this IRenderableImage image) public static void Save(this IRenderableImage image, string path, ImageFileFormat imageFormat = ImageFileFormat.Unspecified, ImageSaveOptions? options = null) { - // TODO: Optimized JPEG saving for file storage with no transforms etc? + if (imageFormat == ImageFileFormat.Unspecified) + { + imageFormat = ImageContext.GetFileFormatFromExtension(path); + } + if (imageFormat == ImageFileFormat.Jpeg && image.IsUntransformedJpegFile(out var jpegPath)) + { + File.Copy(jpegPath, path); + return; + } using var renderedImage = image.Render(); renderedImage.Save(path, imageFormat, options); } @@ -35,6 +63,16 @@ public static void Save(this IRenderableImage image, string path, public static void Save(this IRenderableImage image, Stream stream, ImageFileFormat imageFormat = ImageFileFormat.Unspecified, ImageSaveOptions? options = null) { + if (imageFormat == ImageFileFormat.Unspecified) + { + throw new ArgumentException("Format required to save to a stream", nameof(imageFormat)); + } + if (imageFormat == ImageFileFormat.Jpeg && image.IsUntransformedJpegFile(out var jpegPath)) + { + using var fileStream = File.OpenRead(jpegPath); + fileStream.CopyTo(stream); + return; + } using var renderedImage = image.Render(); renderedImage.Save(stream, imageFormat, options); } diff --git a/NAPS2.Images/NAPS2.Images.csproj b/NAPS2.Images/NAPS2.Images.csproj index 9cc3f1d347..1e739199b7 100644 --- a/NAPS2.Images/NAPS2.Images.csproj +++ b/NAPS2.Images/NAPS2.Images.csproj @@ -18,6 +18,7 @@ + diff --git a/NAPS2.Sdk.Tests/Remoting/ScanServerIntegrationTests.cs b/NAPS2.Sdk.Tests/Remoting/ScanServerIntegrationTests.cs index 3958a82445..6b2c52473f 100644 --- a/NAPS2.Sdk.Tests/Remoting/ScanServerIntegrationTests.cs +++ b/NAPS2.Sdk.Tests/Remoting/ScanServerIntegrationTests.cs @@ -107,6 +107,8 @@ public async Task ScanWithCorrectOptions() Assert.Equal(PaperSource.Flatbed, opts.PaperSource); Assert.Equal(PageSize.Letter, opts.PageSize); Assert.Equal(HorizontalAlign.Right, opts.PageAlign); + Assert.Equal(75, opts.Quality); + Assert.False(opts.MaxQuality); Assert.Single(images); ImageAsserts.Similar(ImageResources.dog, images[0]); @@ -118,7 +120,9 @@ public async Task ScanWithCorrectOptions() Dpi = 300, PaperSource = PaperSource.Feeder, PageSize = PageSize.Legal, - PageAlign = HorizontalAlign.Center + PageAlign = HorizontalAlign.Center, + Quality = 0, + MaxQuality = true }).ToListAsync(); opts = _bridge.LastOptions; @@ -127,6 +131,8 @@ public async Task ScanWithCorrectOptions() Assert.Equal(PaperSource.Feeder, opts.PaperSource); Assert.Equal(PageSize.Legal, opts.PageSize); Assert.Equal(HorizontalAlign.Center, opts.PageAlign); + Assert.Equal(0, opts.Quality); + Assert.True(opts.MaxQuality); Assert.Single(images); ImageAsserts.Similar(ImageResources.dog_gray, images[0]); @@ -138,7 +144,8 @@ public async Task ScanWithCorrectOptions() Dpi = 4800, PaperSource = PaperSource.Duplex, PageSize = PageSize.A3, - PageAlign = HorizontalAlign.Left + PageAlign = HorizontalAlign.Left, + Quality = 100 }).ToListAsync(); opts = _bridge.LastOptions; @@ -148,6 +155,7 @@ public async Task ScanWithCorrectOptions() Assert.Equal(PageSize.A3.WidthInMm, opts.PageSize!.WidthInMm, 1); Assert.Equal(PageSize.A3.HeightInMm, opts.PageSize!.HeightInMm, 1); Assert.Equal(HorizontalAlign.Left, opts.PageAlign); + Assert.Equal(100, opts.Quality); Assert.Single(images); ImageAsserts.Similar(ImageResources.dog_bw, images[0]); } diff --git a/NAPS2.Sdk/Pdf/PdfExporter.cs b/NAPS2.Sdk/Pdf/PdfExporter.cs index 4b874668ba..50cb0279f2 100644 --- a/NAPS2.Sdk/Pdf/PdfExporter.cs +++ b/NAPS2.Sdk/Pdf/PdfExporter.cs @@ -258,16 +258,15 @@ private PageExportState RenderStep(PageExportState state) private IEmbedder GetRenderedImageOrDirectJpegEmbedder(PageExportState state) { - if (state.Image is { Storage: ImageFileStorage fileStorage, TransformState.IsEmpty: true } && - ImageContext.GetFileFormatFromExtension(fileStorage.FullPath) == ImageFileFormat.Jpeg) + if (state.Image.IsUntransformedJpegFile(out var jpegPath)) { // Special case if we have an un-transformed JPEG - just use the original file instead of re-encoding - using var fileStream = new FileStream(fileStorage.FullPath, FileMode.Open, FileAccess.Read); + using var fileStream = new FileStream(jpegPath, FileMode.Open, FileAccess.Read); var jpegHeader = JpegFormatHelper.ReadHeader(fileStream); // Ensure it's not a grayscale image as those are known to not be embeddable if (jpegHeader is { NumComponents: > 1 }) { - return new DirectJpegEmbedder(jpegHeader, fileStorage.FullPath); + return new DirectJpegEmbedder(jpegHeader, jpegPath); } } return new RenderedImageEmbedder(state.Image.Render()); diff --git a/NAPS2.Sdk/Remoting/Server/ScanJob.cs b/NAPS2.Sdk/Remoting/Server/ScanJob.cs index c8d8a4e475..c2eda5c5a0 100644 --- a/NAPS2.Sdk/Remoting/Server/ScanJob.cs +++ b/NAPS2.Sdk/Remoting/Server/ScanJob.cs @@ -44,6 +44,13 @@ public ScanJob(ScanningContext scanningContext, ScanController controller, ScanD _statusCallback?.Invoke(StatusTransition.ScanComplete); _completedTcs.TrySetResult(!args.HasError); }; + + var requestedFormat = settings.DocumentFormat; + ContentType = requestedFormat switch + { + ContentTypes.PNG or ContentTypes.PDF => requestedFormat, + _ => ContentTypes.JPEG + }; var options = new ScanOptions { Device = device, @@ -63,14 +70,11 @@ public ScanJob(ScanningContext scanningContext, ScanController controller, ScanD PageSize = settings.Width > 0 && settings.Height > 0 ? new PageSize(settings.Width / 300m, settings.Height / 300m, PageSizeUnit.Inch) : PageSize.Letter, - PageAlign = SnapToAlignment(settings.XOffset, settings.Width, EsclInputCaps.DEFAULT_MAX_WIDTH) - }; - var requestedFormat = settings.DocumentFormat; - ContentType = requestedFormat switch - { - ContentTypes.PNG or ContentTypes.PDF => requestedFormat, - _ => ContentTypes.JPEG + PageAlign = SnapToAlignment(settings.XOffset, settings.Width, EsclInputCaps.DEFAULT_MAX_WIDTH), + Quality = settings.CompressionFactor ?? ScanOptions.DEFAULT_QUALITY, + MaxQuality = ContentType == ContentTypes.PNG }; + try { _enumerable = controller.Scan(options, _cts.Token).GetAsyncEnumerator(); diff --git a/NAPS2.Sdk/Remoting/Server/ScanServer.cs b/NAPS2.Sdk/Remoting/Server/ScanServer.cs index 9d11c93b9c..deb52c0d87 100644 --- a/NAPS2.Sdk/Remoting/Server/ScanServer.cs +++ b/NAPS2.Sdk/Remoting/Server/ScanServer.cs @@ -62,21 +62,7 @@ private EsclDeviceConfig MakeEsclDeviceConfig(SharedDevice device) MakeAndModel = device.Name, Uuid = device.GetUuid(InstanceId), IconPng = _defaultIconPng, - // TODO: Ideally we want to get the actual device capabilities - PlatenCaps = new EsclInputCaps - { - SettingProfiles = - { - new EsclSettingProfile - { - ColorModes = - { EsclColorMode.RGB24, EsclColorMode.Grayscale8, EsclColorMode.BlackAndWhite1 }, - XResolutionRange = new ResolutionRange(100, 4800, 300), - YResolutionRange = new ResolutionRange(100, 4800, 300), - DocumentFormats = { ContentTypes.PDF, ContentTypes.JPEG, ContentTypes.PNG } - } - } - } + // TODO: Ideally we want to get the actual device capabilities (flatbed/feeder, resolution etc.) }, CreateJob = settings => new ScanJob(_scanningContext, ScanController, device.Device, settings) }; diff --git a/NAPS2.Sdk/Scan/Internal/Escl/EsclScanDriver.cs b/NAPS2.Sdk/Scan/Internal/Escl/EsclScanDriver.cs index 1b708af547..b52fd9d043 100644 --- a/NAPS2.Sdk/Scan/Internal/Escl/EsclScanDriver.cs +++ b/NAPS2.Sdk/Scan/Internal/Escl/EsclScanDriver.cs @@ -314,6 +314,14 @@ private EsclScanSettings GetScanSettings(ScanOptions options, EsclCapabilities c height = Math.Min(height, inputCaps.MaxHeight.Value); } + var contentType = ContentTypes.JPEG; + if (options.BitDepth == BitDepth.BlackAndWhite || options.MaxQuality) + { + bool supportsPng = settingProfile != null && settingProfile.DocumentFormats + .Concat(settingProfile.DocumentFormatsExt).Contains(ContentTypes.PNG); + contentType = supportsPng ? ContentTypes.PNG : ContentTypes.PDF; + } + return new EsclScanSettings { Width = width, @@ -323,16 +331,15 @@ private EsclScanSettings GetScanSettings(ScanOptions options, EsclCapabilities c ColorMode = colorMode, InputSource = inputSource, Duplex = duplex, - DocumentFormat = options.BitDepth == BitDepth.BlackAndWhite || options.MaxQuality - ? ContentTypes.PDF // TODO: Use PNG if available? - : ContentTypes.JPEG, + DocumentFormat = contentType, XOffset = options.PageAlign switch { HorizontalAlign.Left => inputCaps.MaxWidth is > 0 ? inputCaps.MaxWidth.Value - width : 0, HorizontalAlign.Center => inputCaps.MaxWidth is > 0 ? (inputCaps.MaxWidth.Value - width) / 2 : 0, _ => 0 - } - // TODO: Brightness/contrast, quality, etc. + }, + CompressionFactor = caps.CompressionFactorSupport is { Min: 0, Max: 100, Step: 1 } ? options.Quality : null + // TODO: Brightness/contrast, etc. }; } diff --git a/NAPS2.Sdk/Scan/ScanOptions.cs b/NAPS2.Sdk/Scan/ScanOptions.cs index f2ae1d9fd9..8713d66a95 100644 --- a/NAPS2.Sdk/Scan/ScanOptions.cs +++ b/NAPS2.Sdk/Scan/ScanOptions.cs @@ -7,6 +7,8 @@ namespace NAPS2.Scan; /// public class ScanOptions { + public const int DEFAULT_QUALITY = 75; + /// /// The driver type used for scanning. Supported drivers depend on the platform (Windows/Mac/Linux). This usually /// doesn't need to be set as it can be determined from the Device property. @@ -115,7 +117,7 @@ public class ScanOptions /// /// The JPEG compression quality used for storing images. Ignored if MaxQuality is true. /// - public int Quality { get; set; } + public int Quality { get; set; } = DEFAULT_QUALITY; /// /// If non-null, generates thumbnails of the specified size and provides them in the PostProcessingData of each