[Image Resizer] Fix CLI input validation and diagnostics (#49854)

## Summary of the Pull Request

Closes #49852.

Image Resizer CLI previously accepted several invalid inputs as
successful work: unknown options could be consumed as file arguments,
invalid files were silently dropped, unsafe dimensions could reach the
resize engine, and equivalent inputs could process the same source
concurrently.

This change makes `PowerToys.ImageResizerCLI.exe` strict, deterministic,
and diagnosable while preserving the existing lenient command-line
behavior used by the GUI and context-menu path.

## PR Checklist

- [x] Closes: #49852
- [x] **Communication:** Discussed with core contributors
- [x] **Tests:** Added/updated
- [x] **Localization:** End-user-facing diagnostics are localizable
- [x] **Dev docs:** Built-in CLI help and examples updated; no
standalone dev-doc change required
- [x] **New binaries:** No new binaries introduced
- [x] **Documentation updated:** No external user-documentation change
required

## Detailed Description

### Strict CLI parsing without breaking supported syntax

- Adds a strict parse path for the public CLI while keeping the
GUI/context-menu parser lenient.
- Rejects unknown option-like positional tokens before processing.
- Preserves `--`, response files, attached values such as `-w100`, legal
bundles such as `-rq85`, and explicit attached boolean values such as
`-rtrue`.
- Preserves valid file, pipe, and destination values for lenient callers
when another option fails conversion.

### Deterministic and diagnosable input resolution

- Expands wildcards in the final path segment and reports zero-match
patterns.
- Reports missing, unsupported, invalid, and empty inputs instead of
silently filtering them.
- Processes valid files in mixed batches, reports every rejected input,
and returns a non-zero exit code.
- Canonicalizes filesystem paths before deduplication, covering normal,
extended (`\\?\`), long, casing, and parent-reparse aliases while
preserving distinct hard links, final symbolic links, and files in
case-sensitive directories.
- Applies the same strict validation and deduplication to public-CLI
named-pipe input; the GUI/context-menu pipe remains lenient.
- Treats an empty public-CLI pipe as an error.
- Reads redirected stdin using the producing shell's output encoding
with BOM detection, including non-ASCII paths from default cmd and
PowerShell pipelines.

### Validation before file writes

- Rejects negative, non-finite, zero/zero, grouped out-of-range, and
otherwise unsupported custom dimensions.
- Requires a positive effective width for percentage-based Fit and Fill
sizes.
- Treats an out-of-range preset index as an error instead of continuing
with the current preset.
- Validates resize-engine dimensions before integer conversion and
before destination creation.
- Rounds positive fractional Fill targets safely to at least one pixel.
- Deduplicates equivalent paths before parallel processing so
overlapping explicit/glob/pipe inputs cannot race under `--replace`.

### Diagnostics and compatibility

- Includes exception type and HRESULT when a decoder exception has an
empty message.
- Documents and warns that shrink-only remains ignored for
percentage-based sizes, preserving existing behavior.
- Preserves the original UTC modified time after `KeepDateModified +
Replace` by restoring it on the final replaced file.

## Validation

Validation was repeated after merging the latest `main` (`e753ec51fb`)
into the PR branch.

1. Restored with the configured host `NuGet.Config` and built
`src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj` in x64
Release using `tools/build/build.ps1`.
   - Result: exit code 0; errors log empty.
2. Restored and built
`src/modules/imageresizer/tests/ImageResizer.UnitTests.csproj` in x64
Release with the same build script and configured package source.
   - Result: exit code 0; errors log empty.
3. Ran the complete x64 Release Image Resizer unit-test assembly with
Visual Studio `vstest.console.exe`.
   - Result: **205 passed, 0 failed, 0 skipped**.
4. Ran 10 process-level CLI regressions covering:
   - attached boolean parsing (`-rtrue`);
   - grouped out-of-range dimensions without source modification;
   - positive fractional Fill producing a valid `1x100` image;
   - normal/extended/wildcard path deduplication;
- non-ASCII redirected stdin from default PowerShell 7 and cmd
pipelines;
   - empty, invalid, and duplicate named-pipe inputs;
- `KeepDateModified + Replace`, including content change and exact UTC
timestamp preservation.
   - Result: **10/10 passed**.
5. Confirmed both build error logs remained empty, all temporary
fixtures were removed, and the real Image Resizer settings file retained
its original length, timestamp, and SHA-256 hash.

The new Image Resizer UI-test project added on `main` was not run
locally because it requires the repository's WinAppDriver/local-VM
UI-test environment; this PR does not change that UI-test project.

---------

Co-authored-by: Yu Leng (from Dev Box) <yuleng@microsoft.com>
This commit is contained in:
moooyo
2026-08-17 16:50:51 +08:00
committed by GitHub
parent e753ec51fb
commit 75df8d022a
14 changed files with 1674 additions and 79 deletions

View File

@@ -4,7 +4,7 @@
using System;
using System.Globalization;
using System.Text;
using System.IO;
using ImageResizer.Cli;
using ImageResizer.Cli.Telemetry;
@@ -30,7 +30,15 @@ internal static class Program
// Ignore invalid culture and fall back to default.
}
Console.InputEncoding = Encoding.Unicode;
if (Console.IsInputRedirected)
{
// Redirecting shells write pipeline data using their output encoding, which can
// differ from the console input encoding used by Console.In.
Console.SetIn(new StreamReader(
Console.OpenStandardInput(),
Console.OutputEncoding,
detectEncodingFromByteOrderMarks: true));
}
// Initialize logger to file (same as other modules)
CliLogger.Initialize("\\Image Resizer\\CLI");

View File

@@ -0,0 +1,163 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using System.IO.Pipes;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using ImageResizer.Models;
using ImageResizer.Properties;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace ImageResizer.Cli
{
[TestClass]
public class ImageResizerCliExecutorTests
{
[TestMethod]
public void GetShrinkOnlyPercentWarning_ReturnsWarningForEffectivePercentSize()
{
var settings = SettingsWithSize(ResizeUnit.Percent);
settings.ShrinkOnly = true;
var warning = ImageResizerCliExecutor.GetShrinkOnlyPercentWarning(settings);
Assert.AreEqual(Resources.CLI_WarningShrinkOnlyPercent, warning);
}
[TestMethod]
public void GetShrinkOnlyPercentWarning_ReturnsNullForPixelSize()
{
var settings = SettingsWithSize(ResizeUnit.Pixel);
settings.ShrinkOnly = true;
var warning = ImageResizerCliExecutor.GetShrinkOnlyPercentWarning(settings);
Assert.IsNull(warning);
}
[TestMethod]
public void GetShrinkOnlyPercentWarning_ReturnsNullWhenShrinkOnlyIsDisabled()
{
var settings = SettingsWithSize(ResizeUnit.Percent);
settings.ShrinkOnly = false;
var warning = ImageResizerCliExecutor.GetShrinkOnlyPercentWarning(settings);
Assert.IsNull(warning);
}
[TestMethod]
public void GetEffectiveSizeValidationError_RejectsHeightOnlyPercentFit()
{
var options = new CliOptions { Height = 50, Unit = ResizeUnit.Percent, Fit = ResizeFit.Fit };
var settings = SettingsWithSize(ResizeUnit.Pixel);
CliSettingsApplier.Apply(options, settings);
var error = ImageResizerCliExecutor.GetEffectiveSizeValidationError(options, settings);
Assert.AreEqual(Resources.CLI_ErrorPercentWidthRequired, error);
}
[TestMethod]
public void GetEffectiveSizeValidationError_AllowsHeightOnlyPercentStretch()
{
var options = new CliOptions { Height = 50, Unit = ResizeUnit.Percent, Fit = ResizeFit.Stretch };
var settings = SettingsWithSize(ResizeUnit.Pixel);
CliSettingsApplier.Apply(options, settings);
var error = ImageResizerCliExecutor.GetEffectiveSizeValidationError(options, settings);
Assert.IsNull(error);
}
[TestMethod]
public void GetEffectiveSizeValidationError_AllowsPositiveWidthPercentFit()
{
var options = new CliOptions { Width = 50, Unit = ResizeUnit.Percent, Fit = ResizeFit.Fit };
var settings = SettingsWithSize(ResizeUnit.Pixel);
CliSettingsApplier.Apply(options, settings);
var error = ImageResizerCliExecutor.GetEffectiveSizeValidationError(options, settings);
Assert.IsNull(error);
}
[TestMethod]
public void GetSizeIndexValidationError_RejectsOutOfRangePreset()
{
var options = new CliOptions { SizeIndex = 999 };
var settings = SettingsWithSize(ResizeUnit.Pixel);
var error = ImageResizerCliExecutor.GetSizeIndexValidationError(options, settings);
StringAssert.Contains(error, "999");
}
[TestMethod]
public void GetSizeIndexValidationError_AllowsExistingPreset()
{
var options = new CliOptions { SizeIndex = 0 };
var settings = SettingsWithSize(ResizeUnit.Pixel);
var error = ImageResizerCliExecutor.GetSizeIndexValidationError(options, settings);
Assert.IsNull(error);
}
[TestMethod]
public void Run_WithOutOfRangePresetAndReplace_DoesNotModifySource()
{
using var directory = new TestDirectory();
var testDirectory = Path.GetDirectoryName(typeof(ImageResizerCliExecutorTests).Assembly.Location);
var source = Path.Combine(directory, "source.png");
File.Copy(Path.Combine(testDirectory, "Test.png"), source);
var hashBefore = File.ReadAllBytes(source);
var exitCode = new ImageResizerCliExecutor().Run(["--size", "999", "--replace", source]);
Assert.AreEqual(1, exitCode);
CollectionAssert.AreEqual(hashBefore, File.ReadAllBytes(source));
Assert.AreEqual(1, directory.FileNames.Count());
}
[TestMethod]
[Timeout(10000)]
public async Task Run_WithEmptyNamedPipe_ReturnsError()
{
var pipeName = $"ImageResizer-{Guid.NewGuid():N}";
using var pipe = new NamedPipeServerStream(
pipeName,
PipeDirection.Out,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
var writeTask = CompleteEmptyPipeAsync(pipe);
var executor = new ImageResizerCliExecutor();
var exitCode = executor.Run([$@"\\.\pipe\{pipeName}"]);
await writeTask;
Assert.AreEqual(1, exitCode);
Assert.AreEqual("error", executor.CommandName);
}
private static Settings SettingsWithSize(ResizeUnit unit)
{
var settings = new Settings();
settings.CustomSize.Unit = unit;
settings.SelectedSizeIndex = settings.Sizes.Count;
return settings;
}
private static async Task CompleteEmptyPipeAsync(NamedPipeServerStream pipe)
{
await pipe.WaitForConnectionAsync().ConfigureAwait(false);
using var writer = new StreamWriter(pipe, Encoding.Unicode);
}
}
}

View File

@@ -3,6 +3,8 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using ImageResizer.Cli.Commands;
using ImageResizer.Models;
@@ -264,5 +266,270 @@ namespace ImageResizer.Tests.Models
Assert.AreEqual(ResizeUnit.Pixel, options.Unit);
Assert.AreEqual(ResizeFit.Fit, options.Fit);
}
[TestMethod]
public void Parse_WithUnknownLongOption_ReturnsError()
{
var options = CliOptions.ParseForCli(["--bogus", "test.jpg"]);
Assert.AreEqual(1, options.ParseErrors.Count);
StringAssert.Contains(options.ParseErrors[0], "--bogus");
}
[TestMethod]
public void Parse_WithUnknownOptionAfterFile_ReturnsError()
{
var options = CliOptions.ParseForCli(["test.jpg", "--bogus"]);
Assert.AreEqual(1, options.ParseErrors.Count);
StringAssert.Contains(options.ParseErrors[0], "--bogus");
}
[TestMethod]
public void Parse_WithRootRelativeSlashPath_AllowsFile()
{
var options = CliOptions.ParseForCli(["/bogus", "test.jpg"]);
Assert.AreEqual(0, options.ParseErrors.Count);
CollectionAssert.Contains(options.Files.ToList(), "/bogus");
}
[TestMethod]
public void Parse_WithPrefixedDashFileName_AllowsFile()
{
var options = CliOptions.ParseForCli([@".\-photo.jpg"]);
Assert.AreEqual(0, options.ParseErrors.Count);
CollectionAssert.Contains(options.Files.ToList(), @".\-photo.jpg");
}
[TestMethod]
public void Parse_WithEndOfOptions_AllowsOptionLikeFileName()
{
var options = CliOptions.ParseForCli(["--", "--photo.jpg"]);
Assert.AreEqual(0, options.ParseErrors.Count);
CollectionAssert.Contains(options.Files.ToList(), "--photo.jpg");
}
[TestMethod]
public void Parse_LenientModePreservesOptionLikeFileForGui()
{
var options = CliOptions.Parse(["--photo.jpg"]);
Assert.AreEqual(0, options.ParseErrors.Count);
CollectionAssert.Contains(options.Files.ToList(), "--photo.jpg");
}
[TestMethod]
public void Parse_WithUnknownBundledOption_ReturnsError()
{
var options = CliOptions.ParseForCli(["-replace", "test.jpg"]);
Assert.AreEqual(1, options.ParseErrors.Count);
StringAssert.Contains(options.ParseErrors[0], "-replace");
}
[TestMethod]
public void Parse_WithBundledValueOption_PreservesCompatibility()
{
var options = CliOptions.ParseForCli(["-w100", "test.jpg"]);
Assert.AreEqual(0, options.ParseErrors.Count);
Assert.AreEqual(100.0, options.Width);
}
[TestMethod]
public void Parse_WithBundledFlagAndValueOption_PreservesCompatibility()
{
var options = CliOptions.ParseForCli(["-rq85", "test.jpg"]);
Assert.AreEqual(0, options.ParseErrors.Count);
Assert.AreEqual(true, options.Replace);
Assert.AreEqual(85, options.JpegQualityLevel);
}
[TestMethod]
public void Parse_WithInvalidBundleInResponseFile_ReturnsError()
{
var responseFile = Path.Combine(Path.GetTempPath(), $"ImageResizer-{Guid.NewGuid():N}.rsp");
try
{
File.WriteAllLines(responseFile, ["-rphoto.jpg", "photo.jpg"]);
var options = CliOptions.ParseForCli(["@" + responseFile]);
Assert.AreEqual(1, options.ParseErrors.Count);
StringAssert.Contains(options.ParseErrors[0], "-rphoto.jpg");
}
finally
{
File.Delete(responseFile);
}
}
[TestMethod]
public void Parse_WithEndOfOptionsInResponseFile_AllowsOptionLikeFiles()
{
var responseFile = Path.Combine(Path.GetTempPath(), $"ImageResizer-{Guid.NewGuid():N}.rsp");
try
{
File.WriteAllLines(responseFile, ["--"]);
var options = CliOptions.ParseForCli(["@" + responseFile, "-rphoto.jpg", "photo.jpg"]);
Assert.AreEqual(0, options.ParseErrors.Count);
CollectionAssert.Contains(options.Files.ToList(), "-rphoto.jpg");
CollectionAssert.Contains(options.Files.ToList(), "photo.jpg");
}
finally
{
File.Delete(responseFile);
}
}
[TestMethod]
public void Parse_WithConversionError_PreservesFilesAndPipeForLenientCallers()
{
var options = CliOptions.Parse(["--width", "abc", "good.jpg", @"\\.\pipe\ImageResizerTest"]);
Assert.IsTrue(options.ParseErrors.Count > 0);
CollectionAssert.Contains(options.Files.ToList(), "good.jpg");
Assert.AreEqual("ImageResizerTest", options.PipeName);
}
[TestMethod]
public void Parse_WithConversionError_PreservesValidDestinationForLenientCallers()
{
var options = CliOptions.Parse(["/d", @"C:\Output", "--width", "bad", "good.jpg"]);
Assert.IsTrue(options.ParseErrors.Count > 0);
Assert.AreEqual(@"C:\Output", options.DestinationDirectory);
CollectionAssert.Contains(options.Files.ToList(), "good.jpg");
}
[TestMethod]
public void Parse_WithOptionLikeFilenameValue_DoesNotReportBundleError()
{
var options = CliOptions.ParseForCli(["--filename", "-rphoto.jpg", "photo.jpg"]);
Assert.AreEqual(0, options.ParseErrors.Count);
Assert.AreEqual("-rphoto.jpg", options.FileName);
}
[TestMethod]
public void Parse_WithEmptyLongOptionSeparator_ConsumesFollowingOptionLikeValue()
{
var options = CliOptions.ParseForCli(["--filename=", "-rphoto.jpg", "photo.jpg"]);
Assert.AreEqual(0, options.ParseErrors.Count);
Assert.AreEqual("-rphoto.jpg", options.FileName);
CollectionAssert.Contains(options.Files.ToList(), "photo.jpg");
}
[TestMethod]
public void Parse_WithEmptyShortOptionSeparator_ConsumesFollowingOptionLikeValue()
{
var options = CliOptions.ParseForCli(["-n=", "-rphoto.jpg", "photo.jpg"]);
Assert.AreEqual(0, options.ParseErrors.Count);
Assert.AreEqual("-rphoto.jpg", options.FileName);
CollectionAssert.Contains(options.Files.ToList(), "photo.jpg");
}
[TestMethod]
public void Parse_WithExplicitBundledBooleanValue_PreservesCompatibility()
{
var options = CliOptions.ParseForCli(["-r=false", "photo.jpg"]);
Assert.AreEqual(0, options.ParseErrors.Count);
Assert.IsNull(options.Replace);
CollectionAssert.Contains(options.Files.ToList(), "photo.jpg");
}
[DataTestMethod]
[DataRow("-rtrue")]
[DataRow("-rTRUE")]
public void Parse_WithAttachedTrueBooleanValue_PreservesCompatibility(string argument)
{
var options = CliOptions.ParseForCli([argument, "-q85", "photo.jpg"]);
Assert.AreEqual(0, options.ParseErrors.Count);
Assert.AreEqual(true, options.Replace);
Assert.AreEqual(85, options.JpegQualityLevel);
CollectionAssert.Contains(options.Files.ToList(), "photo.jpg");
}
[TestMethod]
public void Parse_WithBundledEmptyValue_DoesNotSkipFollowingInvalidBundle()
{
var options = CliOptions.ParseForCli(["-rn=", "-rphoto.jpg", "photo.jpg"]);
Assert.AreEqual(1, options.ParseErrors.Count);
StringAssert.Contains(options.ParseErrors[0], "-rphoto.jpg");
}
[TestMethod]
public void Parse_WithNegativeWidth_ReturnsError()
{
var options = CliOptions.Parse(["--width", "-100", "--height", "100", "test.jpg"]);
Assert.AreEqual(1, options.ParseErrors.Count);
}
[DataTestMethod]
[DataRow("NaN")]
[DataRow("Infinity")]
[DataRow("-Infinity")]
public void Parse_WithNonFiniteDimension_ReturnsError(string value)
{
var options = CliOptions.Parse(["--width", value, "--height", "100", "test.jpg"]);
Assert.IsTrue(options.ParseErrors.Count > 0);
}
[TestMethod]
public void Parse_WithBothCustomDimensionsZero_ReturnsError()
{
var options = CliOptions.Parse(["--width", "0", "--height", "0", "test.jpg"]);
Assert.AreEqual(1, options.ParseErrors.Count);
}
[TestMethod]
public void Parse_WithOnlyZeroWidth_ReturnsError()
{
var options = CliOptions.Parse(["--width", "0", "test.jpg"]);
Assert.AreEqual(1, options.ParseErrors.Count);
}
[TestMethod]
public void Parse_WithDimensionAboveInt32Range_ReturnsError()
{
var options = CliOptions.Parse(["--width", "2147483648", "--height", "100", "test.jpg"]);
Assert.AreEqual(1, options.ParseErrors.Count);
}
[DataTestMethod]
[DataRow("en-US")]
[DataRow("en-HK")]
public void Parse_WithGroupedDimensionAboveInt32Range_ReturnsError(string cultureName)
{
var originalCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName);
var options = CliOptions.Parse(["--width", "2,147,483,648", "--height", "100", "test.jpg"]);
Assert.AreEqual(1, options.ParseErrors.Count);
}
finally
{
CultureInfo.CurrentCulture = originalCulture;
}
}
}
}

View File

@@ -8,11 +8,15 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.IO.Pipes;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using ImageResizer.Properties;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Microsoft.Win32.SafeHandles;
using Moq;
using Moq.Protected;
@@ -91,6 +95,225 @@ namespace ImageResizer.Models
Assert.AreEqual(2, calls.Count);
}
[TestMethod]
public void FromCliOptionsWithDiagnostics_ReportsMissingAndUnsupportedInputs()
{
using var directory = new TestDirectory();
var unsupported = Path.Combine(directory, "notes.txt");
var missing = Path.Combine(directory, "missing.png");
File.WriteAllText(unsupported, "not an image");
var options = Options(unsupported, missing);
var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options);
Assert.AreEqual(0, batch.Files.Count);
Assert.AreEqual(2, batch.InputErrors.Count);
CollectionAssert.AreEquivalent(
new[] { unsupported, missing },
batch.InputErrors.Select(error => error.File).ToArray());
}
[TestMethod]
public void FromCliOptionsWithDiagnostics_ResolvesWildcardMatches()
{
using var directory = new TestDirectory();
var first = CopyTestImage(directory, "first.jpg");
var second = CopyTestImage(directory, "second.jpg");
var options = Options(Path.Combine(directory, "*.jpg"));
var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options);
Assert.AreEqual(0, batch.InputErrors.Count);
CollectionAssert.AreEquivalent(new[] { first, second }, batch.Files.ToArray());
}
[TestMethod]
public void FromCliOptionsWithDiagnostics_DeduplicatesExplicitAndOverlappingWildcardPaths()
{
using var directory = new TestDirectory();
var file = CopyTestImage(directory, "overlap.jpg");
var options = Options(file, file.ToUpperInvariant(), Path.Combine(directory, "*.jpg"));
var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options);
Assert.AreEqual(0, batch.InputErrors.Count);
CollectionAssert.AreEqual(new[] { file }, batch.Files.ToArray());
}
[TestMethod]
public void FromCliOptionsWithDiagnostics_DeduplicatesExtendedPathAlias()
{
using var directory = new TestDirectory();
var file = CopyTestImage(directory, "alias.jpg");
var options = Options(file, ToExtendedPath(file));
var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options);
Assert.AreEqual(0, batch.InputErrors.Count);
CollectionAssert.AreEqual(new[] { file }, batch.Files.ToArray());
}
[TestMethod]
public void FromCliOptionsWithDiagnostics_DeduplicatesLongNormalAndExtendedPathAliases()
{
using var directory = new TestDirectory();
var longDirectory = Path.Combine(directory, new string('a', 120), new string('b', 120));
Directory.CreateDirectory(longDirectory);
var sourceDirectory = Path.GetDirectoryName(typeof(ResizeBatchTests).Assembly.Location);
var file = Path.Combine(longDirectory, "long-path.jpg");
File.Copy(Path.Combine(sourceDirectory, "Test.jpg"), file);
Assert.IsTrue(file.Length > 260);
var normalAlias = file.ToUpperInvariant();
var batch = ResizeBatch.FromCliOptionsWithDiagnostics(
null,
Options(normalAlias, ToExtendedPath(file)));
Assert.AreEqual(0, batch.InputErrors.Count);
CollectionAssert.AreEqual(new[] { normalAlias }, batch.Files.ToArray());
}
[TestMethod]
public void FromCliOptionsWithDiagnostics_PreservesCaseOnlyFilesInCaseSensitiveDirectory()
{
using var directory = new TestDirectory();
if (!TryEnableCaseSensitivity(directory, out var errorCode))
{
if (errorCode is 1 or 50 or 87)
{
Assert.Inconclusive($"The test filesystem does not support per-directory case sensitivity (Win32 error {errorCode}).");
}
Assert.Fail($"Failed to enable per-directory case sensitivity (Win32 error {errorCode}).");
}
var first = CopyTestImage(directory, "Photo.jpg");
var second = CopyTestImage(directory, "photo.jpg");
var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, Options(first, second));
Assert.AreEqual(0, batch.InputErrors.Count);
CollectionAssert.AreEquivalent(new[] { first, second }, batch.Files.ToArray());
}
[TestMethod]
[Timeout(10000)]
public async Task FromCliOptionsWithDiagnostics_NamedPipeReportsInvalidInputsAndDeduplicatesValidFiles()
{
using var directory = new TestDirectory();
var valid = CopyTestImage(directory, "pipe.jpg");
var unsupported = Path.Combine(directory, "notes.txt");
var missing = Path.Combine(directory, "missing.jpg");
File.WriteAllText(unsupported, "not an image");
var pipeName = $"ImageResizer-{Guid.NewGuid():N}";
using var pipe = CreatePipeServer(pipeName);
var writeTask = WritePipeLinesAsync(pipe, ToExtendedPath(valid), unsupported, missing);
var options = Options(valid);
options.PipeName = pipeName;
var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options);
await writeTask;
CollectionAssert.AreEqual(new[] { valid }, batch.Files.ToArray());
CollectionAssert.AreEquivalent(
new[] { unsupported, missing },
batch.InputErrors.Select(error => error.File).ToArray());
}
[TestMethod]
[Timeout(10000)]
public async Task FromCliOptions_NamedPipeRemainsLenient()
{
using var directory = new TestDirectory();
var valid = CopyTestImage(directory, "pipe.jpg");
var missing = Path.Combine(directory, "missing.jpg");
var pipeName = $"ImageResizer-{Guid.NewGuid():N}";
using var pipe = CreatePipeServer(pipeName);
var writeTask = WritePipeLinesAsync(pipe, valid, missing);
var options = Options();
options.PipeName = pipeName;
var batch = ResizeBatch.FromCliOptions(null, options);
await writeTask;
CollectionAssert.AreEqual(new[] { valid }, batch.Files.ToArray());
Assert.AreEqual(0, batch.InputErrors.Count);
}
[TestMethod]
public void FromCliOptionsWithDiagnostics_ReportsWildcardWithNoMatches()
{
using var directory = new TestDirectory();
var pattern = Path.Combine(directory, "*.jpg");
var options = Options(pattern);
var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options);
Assert.AreEqual(0, batch.Files.Count);
Assert.AreEqual(1, batch.InputErrors.Count);
Assert.AreEqual(pattern, batch.InputErrors[0].File);
}
[TestMethod]
public void FromCliOptionsWithDiagnostics_PreservesValidFilesInMixedInput()
{
using var directory = new TestDirectory();
var valid = CopyTestImage(directory, "valid.jpg");
var missing = Path.Combine(directory, "missing.jpg");
var options = Options(valid, missing);
var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, options);
CollectionAssert.AreEqual(new[] { valid }, batch.Files.ToArray());
Assert.AreEqual(1, batch.InputErrors.Count);
Assert.AreEqual(missing, batch.InputErrors[0].File);
}
[TestMethod]
public void FromCliOptions_RemainsLenientForInvalidInputs()
{
using var directory = new TestDirectory();
var options = Options(Path.Combine(directory, "missing.jpg"));
var batch = ResizeBatch.FromCliOptions(null, options);
Assert.AreEqual(0, batch.Files.Count);
Assert.AreEqual(0, batch.InputErrors.Count);
}
[TestMethod]
public async Task ProcessIncludesStrictInputDiagnostics()
{
using var directory = new TestDirectory();
var firstMissing = Path.Combine(directory, "first-missing.jpg");
var secondMissing = Path.Combine(directory, "second-missing.jpg");
var batch = ResizeBatch.FromCliOptionsWithDiagnostics(null, Options(firstMissing, secondMissing));
var errors = (await batch.ProcessAsync((_, __) => { }, CancellationToken.None)).ToList();
Assert.AreEqual(2, errors.Count);
CollectionAssert.AreEqual(new[] { firstMissing, secondMissing }, errors.Select(error => error.File).ToArray());
}
[TestMethod]
public void FormatErrorMessage_PreservesNonEmptyMessage()
{
const string message = "Decoder failed.";
var result = ResizeBatch.FormatErrorMessage(new InvalidOperationException(message));
Assert.AreEqual(message, result);
}
[TestMethod]
public void FormatErrorMessage_UsesTypeAndHResultWhenMessageIsEmpty()
{
var result = ResizeBatch.FormatErrorMessage(new EmptyMessageException());
StringAssert.Contains(result, nameof(EmptyMessageException));
StringAssert.Contains(result, "0x88982F60");
}
private static ResizeBatch CreateBatch(Action<string> executeAction)
{
var mock = new Mock<ResizeBatch> { CallBase = true };
@@ -104,5 +327,119 @@ namespace ImageResizer.Models
return mock.Object;
}
private static CliOptions Options(params string[] files)
{
var options = new CliOptions();
foreach (var file in files)
{
options.Files.Add(file);
}
return options;
}
private static string CopyTestImage(TestDirectory directory, string fileName)
{
var sourceDirectory = Path.GetDirectoryName(typeof(ResizeBatchTests).Assembly.Location);
var destination = Path.Combine(directory, fileName);
File.Copy(Path.Combine(sourceDirectory, "Test.jpg"), destination);
return destination;
}
private static string ToExtendedPath(string path)
=> path.StartsWith(@"\\", StringComparison.Ordinal)
? string.Concat(@"\\?\UNC\", path.AsSpan(2))
: @"\\?\" + path;
private static NamedPipeServerStream CreatePipeServer(string pipeName)
=> new NamedPipeServerStream(
pipeName,
PipeDirection.Out,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
private static async Task WritePipeLinesAsync(NamedPipeServerStream pipe, params string[] lines)
{
await pipe.WaitForConnectionAsync().ConfigureAwait(false);
using var writer = new StreamWriter(pipe, Encoding.Unicode);
foreach (var line in lines)
{
await writer.WriteLineAsync(line).ConfigureAwait(false);
}
}
private static bool TryEnableCaseSensitivity(string directory, out int errorCode)
{
const uint fileWriteAttributes = 0x00000100;
const uint fileShareRead = 0x00000001;
const uint fileShareWrite = 0x00000002;
const uint fileShareDelete = 0x00000004;
const uint openExisting = 3;
const uint fileFlagBackupSemantics = 0x02000000;
const uint fileCaseSensitiveDirectory = 0x00000001;
using SafeFileHandle handle = CreateFile(
directory,
fileWriteAttributes,
fileShareRead | fileShareWrite | fileShareDelete,
IntPtr.Zero,
openExisting,
fileFlagBackupSemantics,
IntPtr.Zero);
if (handle.IsInvalid)
{
errorCode = Marshal.GetLastWin32Error();
return false;
}
var info = new FileCaseSensitiveInfo { Flags = fileCaseSensitiveDirectory };
var result = SetFileInformationByHandle(
handle,
FileInfoByHandleClass.FileCaseSensitiveInfo,
ref info,
(uint)Marshal.SizeOf<FileCaseSensitiveInfo>());
errorCode = result ? 0 : Marshal.GetLastWin32Error();
return result;
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFile(
string fileName,
uint desiredAccess,
uint shareMode,
IntPtr securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
IntPtr templateFile);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetFileInformationByHandle(
SafeFileHandle fileHandle,
FileInfoByHandleClass fileInformationClass,
ref FileCaseSensitiveInfo fileInformation,
uint bufferSize);
private sealed class EmptyMessageException : Exception
{
public EmptyMessageException()
: base(string.Empty)
{
HResult = unchecked((int)0x88982F60);
}
}
private enum FileInfoByHandleClass
{
FileCaseSensitiveInfo = 23,
}
[StructLayout(LayoutKind.Sequential)]
private struct FileCaseSensitiveInfo
{
public uint Flags;
}
}
}

View File

@@ -76,7 +76,8 @@ namespace ImageResizer.Models
{
var path = Path.Combine(_directory, "Test.png");
File.Copy("Test.png", path);
var expectedDateModified = new DateTime(2001, 2, 3, 4, 5, 6, DateTimeKind.Utc);
File.SetLastWriteTimeUtc(path, expectedDateModified);
var originalDateModified = File.GetLastWriteTimeUtc(path);
var operation = new ResizeOperation(
@@ -91,7 +92,9 @@ namespace ImageResizer.Models
await operation.ExecuteAsync();
Assert.AreEqual(expectedDateModified, originalDateModified);
Assert.AreEqual(originalDateModified, File.GetLastWriteTimeUtc(_directory.File()));
await AssertEx.ImageAsync(_directory.File(), decoder => Assert.AreEqual(96u, decoder.PixelWidth));
}
[TestMethod]
@@ -334,6 +337,44 @@ namespace ImageResizer.Models
decoder => Assert.AreEqual(96u, decoder.PixelHeight));
}
[TestMethod]
public async Task TransformRejectsScaledDimensionsAboveInt32RangeBeforeWriting()
{
var operation = new ResizeOperation(
"Test.png",
_directory,
Settings(
settings =>
{
settings.SelectedSize.Fit = ResizeFit.Stretch;
settings.SelectedSize.Width = (double)int.MaxValue + 1;
settings.SelectedSize.Height = 100;
}));
await Assert.ThrowsExactlyAsync<ArgumentOutOfRangeException>(() => operation.ExecuteAsync());
Assert.AreEqual(0, _directory.FileNames.Count());
}
[TestMethod]
public async Task TransformRejectsNegativeFractionalDimensionsBeforeRounding()
{
var operation = new ResizeOperation(
"Test.png",
_directory,
Settings(
settings =>
{
settings.SelectedSize.Fit = ResizeFit.Stretch;
settings.SelectedSize.Width = -0.4;
settings.SelectedSize.Height = 100;
}));
await Assert.ThrowsExactlyAsync<ArgumentOutOfRangeException>(() => operation.ExecuteAsync());
Assert.AreEqual(0, _directory.FileNames.Count());
}
[TestMethod]
public async Task TransformHonorsUnit()
{
@@ -395,6 +436,31 @@ namespace ImageResizer.Models
});
}
[TestMethod]
public async Task TransformRoundsPositiveFractionalFillDimensionsToAtLeastOnePixel()
{
var operation = new ResizeOperation(
"TestPortrait.png",
_directory,
Settings(
settings =>
{
settings.SelectedSize.Fit = ResizeFit.Fill;
settings.SelectedSize.Width = 0.4;
settings.SelectedSize.Height = 100;
}));
await operation.ExecuteAsync();
await AssertEx.ImageAsync(
_directory.File(),
decoder =>
{
Assert.AreEqual(1u, decoder.PixelWidth);
Assert.AreEqual(100u, decoder.PixelHeight);
});
}
[TestMethod]
public async Task TransformHonorsFitWhenStretch()
{

View File

@@ -31,7 +31,7 @@ namespace ImageResizer.Cli
/// <returns>Exit code.</returns>
public int Run(string[] args)
{
var cliOptions = CliOptions.Parse(args);
var cliOptions = CliOptions.ParseForCli(args);
if (cliOptions.ParseErrors.Count > 0)
{
@@ -60,7 +60,9 @@ namespace ImageResizer.Cli
return 0;
}
if (cliOptions.Files.Count == 0 && string.IsNullOrEmpty(cliOptions.PipeName))
if (cliOptions.Files.Count == 0 &&
string.IsNullOrEmpty(cliOptions.PipeName) &&
!Console.IsInputRedirected)
{
Console.WriteLine(Resources.CLI_NoInputFiles);
CliOptions.PrintUsage();
@@ -73,10 +75,44 @@ namespace ImageResizer.Cli
private async Task<int> RunSilentModeAsync(CliOptions cliOptions)
{
var batch = ResizeBatch.FromCliOptions(Console.In, cliOptions);
var batch = ResizeBatch.FromCliOptionsWithDiagnostics(Console.In, cliOptions);
if (batch.Files.Count == 0 &&
batch.InputErrors.Count == 0)
{
Console.WriteLine(Resources.CLI_NoInputFiles);
CliOptions.PrintUsage();
CommandName = "error";
return 1;
}
var settings = Settings.Default;
var sizeIndexValidationError = GetSizeIndexValidationError(cliOptions, settings);
if (sizeIndexValidationError != null)
{
Console.Error.WriteLine(sizeIndexValidationError);
CliLogger.Error($"Validation error: {sizeIndexValidationError}");
CommandName = "error";
return 1;
}
CliSettingsApplier.Apply(cliOptions, settings);
var sizeValidationError = GetEffectiveSizeValidationError(cliOptions, settings);
if (sizeValidationError != null)
{
Console.Error.WriteLine(sizeValidationError);
CliLogger.Error($"Validation error: {sizeValidationError}");
CommandName = "error";
return 1;
}
var compatibilityWarning = GetShrinkOnlyPercentWarning(settings);
if (compatibilityWarning != null)
{
Console.Error.WriteLine(compatibilityWarning);
CliLogger.Warn(compatibilityWarning);
}
CliLogger.Info($"CLI mode: processing {batch.Files.Count} files");
// Use accessible line-based progress if requested or detected
@@ -130,5 +166,38 @@ namespace ImageResizer.Cli
Console.WriteLine(Resources.CLI_AllFilesProcessed);
return 0;
}
internal static string GetShrinkOnlyPercentWarning(Settings settings)
=> settings.ShrinkOnly && settings.SelectedSize.Unit == ResizeUnit.Percent
? Resources.CLI_WarningShrinkOnlyPercent
: null;
internal static string GetEffectiveSizeValidationError(CliOptions options, Settings settings)
{
if ((options.Width.HasValue || options.Height.HasValue) &&
settings.SelectedSize.Unit == ResizeUnit.Percent &&
settings.SelectedSize.Fit != ResizeFit.Stretch &&
settings.SelectedSize.Width <= 0)
{
return Resources.CLI_ErrorPercentWidthRequired;
}
return null;
}
internal static string GetSizeIndexValidationError(CliOptions options, Settings settings)
{
if (options.SizeIndex.HasValue &&
(options.SizeIndex.Value < 0 || options.SizeIndex.Value >= settings.Sizes.Count))
{
return string.Format(
CultureInfo.InvariantCulture,
Resources.CLI_ErrorSizeIndexOutOfRange,
options.SizeIndex.Value,
settings.Sizes.Count - 1);
}
return null;
}
}
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace ImageResizer.Cli.Options
{
internal static class DimensionOptionValidator
{
internal static string Validate(string valueText)
{
if (string.IsNullOrWhiteSpace(valueText))
{
return null;
}
if (!double.TryParse(valueText, out var value))
{
// Leave type-conversion errors to System.CommandLine.
return null;
}
if (double.IsNaN(value) || double.IsInfinity(value) || value < 0)
{
return Properties.Resources.CLI_ErrorInvalidDimension;
}
if (value > int.MaxValue)
{
return Properties.Resources.Error_DimensionOutOfRange;
}
return null;
}
}
}

View File

@@ -13,6 +13,14 @@ namespace ImageResizer.Cli.Options
public HeightOption()
: base(_aliases, Properties.Resources.CLI_Option_Height)
{
AddValidator(result =>
{
var error = DimensionOptionValidator.Validate(result.Tokens.Count == 1 ? result.Tokens[0].Value : null);
if (error != null)
{
result.ErrorMessage = error;
}
});
}
}
}

View File

@@ -13,6 +13,14 @@ namespace ImageResizer.Cli.Options
public WidthOption()
: base(_aliases, Properties.Resources.CLI_Option_Width)
{
AddValidator(result =>
{
var error = DimensionOptionValidator.Validate(result.Tokens.Count == 1 ? result.Tokens[0].Value : null);
if (error != null)
{
result.ErrorMessage = error;
}
});
}
}
}

View File

@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.CommandLine.Parsing;
using System.Globalization;
using System.Linq;
using ImageResizer.Cli.Commands;
using ImageResizer.Helpers;
@@ -61,63 +62,284 @@ namespace ImageResizer.Models
private static bool? ToBoolOrNull(bool value) => value ? true : null;
public static CliOptions Parse(string[] args)
=> ParseCore(args, rejectOptionLikeFiles: false);
internal static CliOptions ParseForCli(string[] args)
=> ParseCore(args, rejectOptionLikeFiles: true);
private static CliOptions ParseCore(string[] args, bool rejectOptionLikeFiles)
{
var options = new CliOptions();
var cmd = new ImageResizerRootCommand();
var parseResult = new Parser(cmd).Parse(args);
var errors = new List<string>(parseResult.Errors.Count + 1);
if (parseResult.Errors.Count > 0)
foreach (var error in parseResult.Errors)
{
var errors = new List<string>(parseResult.Errors.Count);
foreach (var error in parseResult.Errors)
{
errors.Add(error.Message);
}
errors.Add(error.Message);
}
var files = parseResult.GetValueForArgument(cmd.FilesArgument);
PopulateInputs(options, files);
PopulateOptionValues(options, cmd, parseResult);
if (errors.Count > 0)
{
options.ParseErrors = new ReadOnlyCollection<string>(errors);
return options;
}
if ((options.Width.HasValue || options.Height.HasValue) &&
(options.Width ?? 0) == 0 &&
(options.Height ?? 0) == 0)
{
errors.Add(Properties.Resources.CLI_ErrorZeroDimensions);
options.ParseErrors = new ReadOnlyCollection<string>(errors);
}
options.ShowHelp = parseResult.GetValueForOption(cmd.HelpOption);
options.ShowConfig = parseResult.GetValueForOption(cmd.ShowConfigOption);
options.DestinationDirectory = parseResult.GetValueForOption(cmd.DestinationOption);
options.Width = parseResult.GetValueForOption(cmd.WidthOption);
options.Height = parseResult.GetValueForOption(cmd.HeightOption);
options.Unit = parseResult.GetValueForOption(cmd.UnitOption);
options.Fit = parseResult.GetValueForOption(cmd.FitOption);
options.SizeIndex = parseResult.GetValueForOption(cmd.SizeOption);
options.ShrinkOnly = ToBoolOrNull(parseResult.GetValueForOption(cmd.ShrinkOnlyOption));
options.Replace = ToBoolOrNull(parseResult.GetValueForOption(cmd.ReplaceOption));
options.IgnoreOrientation = ToBoolOrNull(parseResult.GetValueForOption(cmd.IgnoreOrientationOption));
options.RemoveMetadata = ToBoolOrNull(parseResult.GetValueForOption(cmd.RemoveMetadataOption));
options.KeepDateModified = ToBoolOrNull(parseResult.GetValueForOption(cmd.KeepDateModifiedOption));
options.ProgressLines = ToBoolOrNull(parseResult.GetValueForOption(cmd.ProgressLinesOption));
options.JpegQualityLevel = parseResult.GetValueForOption(cmd.QualityOption);
options.FileName = parseResult.GetValueForOption(cmd.FileNameOption);
var files = parseResult.GetValueForArgument(cmd.FilesArgument);
if (files != null)
if (rejectOptionLikeFiles)
{
const string pipeNamePrefix = "\\\\.\\pipe\\";
foreach (var file in files)
var validationArgs = ExpandTokensForValidation(args);
AddOptionLikeFileErrors(validationArgs, parseResult, cmd.Options, options.Files, errors);
if (errors.Count > 0)
{
if (file.StartsWith(pipeNamePrefix, StringComparison.OrdinalIgnoreCase))
{
options.PipeName = file.Substring(pipeNamePrefix.Length);
}
else
{
options.Files.Add(file);
}
options.ParseErrors = new ReadOnlyCollection<string>(errors);
}
}
return options;
}
private static void PopulateOptionValues(CliOptions options, ImageResizerRootCommand command, ParseResult parseResult)
{
options.ShowHelp = GetValidOptionValue(parseResult, command.HelpOption);
options.ShowConfig = GetValidOptionValue(parseResult, command.ShowConfigOption);
options.DestinationDirectory = GetValidOptionValue(parseResult, command.DestinationOption);
options.Width = GetValidOptionValue(parseResult, command.WidthOption);
options.Height = GetValidOptionValue(parseResult, command.HeightOption);
options.Unit = GetValidOptionValue(parseResult, command.UnitOption);
options.Fit = GetValidOptionValue(parseResult, command.FitOption);
options.SizeIndex = GetValidOptionValue(parseResult, command.SizeOption);
options.ShrinkOnly = ToBoolOrNull(GetValidOptionValue(parseResult, command.ShrinkOnlyOption));
options.Replace = ToBoolOrNull(GetValidOptionValue(parseResult, command.ReplaceOption));
options.IgnoreOrientation = ToBoolOrNull(GetValidOptionValue(parseResult, command.IgnoreOrientationOption));
options.RemoveMetadata = ToBoolOrNull(GetValidOptionValue(parseResult, command.RemoveMetadataOption));
options.KeepDateModified = ToBoolOrNull(GetValidOptionValue(parseResult, command.KeepDateModifiedOption));
options.ProgressLines = ToBoolOrNull(GetValidOptionValue(parseResult, command.ProgressLinesOption));
options.JpegQualityLevel = GetValidOptionValue(parseResult, command.QualityOption);
options.FileName = GetValidOptionValue(parseResult, command.FileNameOption);
}
private static T GetValidOptionValue<T>(ParseResult parseResult, System.CommandLine.Option<T> option)
{
var optionResult = parseResult.FindResultFor(option);
if (optionResult != null && !string.IsNullOrEmpty(optionResult.ErrorMessage))
{
return default;
}
try
{
return parseResult.GetValueForOption(option);
}
catch (InvalidOperationException)
{
return default;
}
}
private static void AddOptionLikeFileErrors(
IReadOnlyList<string> args,
ParseResult parseResult,
IReadOnlyList<System.CommandLine.Option> options,
ICollection<string> files,
ICollection<string> errors)
{
var escapedCounts = new Dictionary<string, int>(StringComparer.Ordinal);
var afterEndOfOptions = false;
foreach (var token in parseResult.Tokens)
{
if (!afterEndOfOptions && token.Type == TokenType.DoubleDash)
{
afterEndOfOptions = true;
continue;
}
if (afterEndOfOptions && LooksLikeOption(token.Value))
{
escapedCounts.TryGetValue(token.Value, out var count);
escapedCounts[token.Value] = count + 1;
}
}
var optionLikeErrors = new List<string>();
foreach (var file in files.Reverse())
{
if (!LooksLikeOption(file))
{
continue;
}
if (escapedCounts.TryGetValue(file, out var count) && count > 0)
{
escapedCounts[file] = count - 1;
continue;
}
optionLikeErrors.Add(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ErrorUnknownOption, file));
}
optionLikeErrors.Reverse();
foreach (var error in optionLikeErrors)
{
errors.Add(error);
}
AddInvalidBundleErrors(args, options, errors);
}
private static bool LooksLikeOption(string value)
=> value?.Length > 1 && value[0] == '-';
private static void AddInvalidBundleErrors(
IReadOnlyList<string> args,
IReadOnlyList<System.CommandLine.Option> options,
ICollection<string> errors)
{
var aliasMap = options
.SelectMany(option => option.Aliases.Select(alias => (Alias: alias, Option: option)))
.ToDictionary(item => item.Alias, item => item.Option, StringComparer.Ordinal);
var shortOptions = aliasMap
.Where(item => item.Key.Length == 2 && item.Key[0] == '-' && item.Key[1] != '-')
.ToDictionary(item => item.Key[1], item => item.Value);
for (var argumentIndex = 0; argumentIndex < args.Count; argumentIndex++)
{
var arg = args[argumentIndex];
if (arg == "--")
{
break;
}
if (aliasMap.TryGetValue(arg, out var exactOption))
{
if (exactOption.ValueType != typeof(bool) && argumentIndex + 1 < args.Count)
{
argumentIndex++;
}
continue;
}
var handledSeparatedOption = false;
foreach (var (alias, option) in aliasMap)
{
if (arg.Length > alias.Length &&
arg.StartsWith(alias, StringComparison.Ordinal) &&
(arg[alias.Length] == '=' || arg[alias.Length] == ':'))
{
var hasAttachedValue = arg.Length > alias.Length + 1;
if (option.ValueType != typeof(bool) && !hasAttachedValue && argumentIndex + 1 < args.Count)
{
argumentIndex++;
}
handledSeparatedOption = true;
break;
}
}
if (handledSeparatedOption)
{
continue;
}
if (arg.StartsWith("--", StringComparison.Ordinal) || arg.StartsWith('/'))
{
continue;
}
if (arg.Length <= 2 || arg[0] != '-' || arg[1] == '-')
{
continue;
}
var consumedFlag = false;
for (var index = 1; index < arg.Length; index++)
{
if (!shortOptions.TryGetValue(arg[index], out var option))
{
if (consumedFlag)
{
errors.Add(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ErrorUnknownOption, arg));
}
break;
}
if (option.ValueType != typeof(bool))
{
var attachedValue = arg.Substring(index + 1);
if (attachedValue.Length == 0 && argumentIndex + 1 < args.Count)
{
argumentIndex++;
}
// The remainder, when present, is the attached value for this option.
break;
}
consumedFlag = true;
if (bool.TryParse(arg.AsSpan(index + 1), out _))
{
// System.CommandLine accepts an explicit boolean value attached to a
// short option (for example, -rtrue). The value consumes the remainder.
break;
}
}
}
}
private static IReadOnlyList<string> ExpandTokensForValidation(string[] args)
{
var tokenizerCommand = new System.CommandLine.RootCommand();
var tokenizerArgument = new System.CommandLine.Argument<string[]>("tokens")
{
Arity = System.CommandLine.ArgumentArity.ZeroOrMore,
};
tokenizerCommand.AddArgument(tokenizerArgument);
return new Parser(tokenizerCommand)
.Parse(args)
.Tokens
.Select(token => token.Type == TokenType.DoubleDash ? "--" : token.Value)
.ToList();
}
private static void PopulateInputs(CliOptions options, string[] files)
{
if (files == null)
{
return;
}
const string pipeNamePrefix = "\\\\.\\pipe\\";
foreach (var file in files)
{
if (file.StartsWith(pipeNamePrefix, StringComparison.OrdinalIgnoreCase))
{
options.PipeName = file.Substring(pipeNamePrefix.Length);
}
else
{
options.Files.Add(file);
}
}
}
public static void PrintConfig(ImageResizer.Properties.Settings settings)
{
var getString = ResourceLoaderInstance.GetString;

View File

@@ -7,26 +7,34 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Abstractions;
using System.IO.Pipes;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using ImageResizer.Properties;
using ImageResizer.Services;
using Microsoft.Win32.SafeHandles;
namespace ImageResizer.Models
{
public class ResizeBatch
{
private readonly IFileSystem _fileSystem = new FileSystem();
private readonly List<ResizeError> _inputErrors = [];
private readonly HashSet<string> _resolvedInputPaths = new(StringComparer.Ordinal);
private static IAISuperResolutionService _aiSuperResolutionService;
public string DestinationDirectory { get; set; }
public ICollection<string> Files { get; } = new List<string>();
internal IReadOnlyList<ResizeError> InputErrors => _inputErrors;
public static void SetAiSuperResolutionService(IAISuperResolutionService service)
{
_aiSuperResolutionService = service;
@@ -72,6 +80,18 @@ namespace ImageResizer.Models
/// <param name="options">The parsed CLI options.</param>
/// <returns>A ResizeBatch instance.</returns>
public static ResizeBatch FromCliOptions(TextReader standardInput, CliOptions options)
=> FromCliOptionsCore(standardInput, options, reportInvalidInputs: false);
/// <summary>
/// Creates a batch for the public CLI and preserves a diagnostic for every rejected input.
/// </summary>
/// <param name="standardInput">Standard input stream for reading additional file paths.</param>
/// <param name="options">The parsed CLI options.</param>
/// <returns>A resize batch containing valid files and input diagnostics.</returns>
internal static ResizeBatch FromCliOptionsWithDiagnostics(TextReader standardInput, CliOptions options)
=> FromCliOptionsCore(standardInput, options, reportInvalidInputs: true);
private static ResizeBatch FromCliOptionsCore(TextReader standardInput, CliOptions options, bool reportInvalidInputs)
{
var batch = new ResizeBatch
{
@@ -80,11 +100,13 @@ namespace ImageResizer.Models
foreach (var file in options.Files)
{
// Convert relative paths to absolute paths
var absolutePath = Path.IsPathRooted(file) ? file : Path.GetFullPath(file);
if (IsValidImagePath(absolutePath))
if (reportInvalidInputs)
{
batch.Files.Add(absolutePath);
AddStrictInput(batch, file);
}
else
{
AddLenientInput(batch, file);
}
}
@@ -97,11 +119,13 @@ namespace ImageResizer.Models
{
while ((file = standardInput.ReadLine()) != null)
{
// Convert relative paths to absolute paths
var absolutePath = Path.IsPathRooted(file) ? file : Path.GetFullPath(file);
if (IsValidImagePath(absolutePath))
if (reportInvalidInputs)
{
batch.Files.Add(absolutePath);
AddStrictInput(batch, file);
}
else
{
AddLenientInput(batch, file);
}
}
}
@@ -121,8 +145,13 @@ namespace ImageResizer.Models
// Read file paths from the named pipe
while ((file = sr.ReadLine()) != null)
{
if (IsValidImagePath(file))
if (reportInvalidInputs)
{
AddStrictInput(batch, file);
}
else if (IsValidImagePath(file))
{
// Preserve the legacy GUI/context-menu behavior for named-pipe input.
batch.Files.Add(file);
}
}
@@ -133,6 +162,232 @@ namespace ImageResizer.Models
return batch;
}
private static void AddLenientInput(ResizeBatch batch, string input)
{
// Keep the GUI and context-menu behavior unchanged: unsupported selections are ignored.
var absolutePath = Path.IsPathRooted(input) ? input : Path.GetFullPath(input);
if (IsValidImagePath(absolutePath))
{
batch.Files.Add(absolutePath);
}
}
private static void AddStrictInput(ResizeBatch batch, string input)
{
if (string.IsNullOrWhiteSpace(input))
{
AddInputError(batch, input ?? string.Empty, Resources.CLI_ErrorFileNotFound);
return;
}
try
{
if (ContainsWildcard(input))
{
AddWildcardMatches(batch, input);
}
else
{
AddResolvedInput(batch, input, Path.GetFullPath(input));
}
}
catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException)
{
AddInputError(
batch,
input,
string.Format(CultureInfo.InvariantCulture, Resources.CLI_ErrorInvalidInputPath, ex.Message));
}
}
private static void AddWildcardMatches(ResizeBatch batch, string input)
{
var absolutePattern = Path.GetFullPath(input);
var directory = Path.GetDirectoryName(absolutePattern);
var pattern = Path.GetFileName(absolutePattern);
if (string.IsNullOrEmpty(directory) || ContainsWildcard(directory))
{
AddInputError(batch, input, Resources.CLI_ErrorWildcardInDirectory);
return;
}
List<string> matches = Directory.Exists(directory)
? Directory.EnumerateFiles(directory, pattern, SearchOption.TopDirectoryOnly)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToList()
: [];
if (matches.Count == 0)
{
AddInputError(batch, input, Resources.CLI_ErrorNoWildcardMatches);
return;
}
foreach (var match in matches)
{
AddResolvedInput(batch, match, match);
}
}
private static void AddResolvedInput(ResizeBatch batch, string input, string absolutePath)
{
var normalizedPath = Path.GetFullPath(absolutePath);
if (!File.Exists(normalizedPath))
{
AddInputError(batch, input, Resources.CLI_ErrorFileNotFound);
return;
}
if (!ValidImageExtensions.Contains(Path.GetExtension(normalizedPath)))
{
AddInputError(batch, input, Resources.CLI_ErrorUnsupportedFileType);
return;
}
if (batch.TryAddResolvedInput(normalizedPath))
{
batch.Files.Add(normalizedPath);
}
}
private bool TryAddResolvedInput(string path)
=> _resolvedInputPaths.Add(GetCanonicalPathKey(path));
private static string GetCanonicalPathKey(string path)
{
var fullPath = Path.GetFullPath(path);
var apiPath = AddExtendedPathPrefix(fullPath);
var finalPath = TryGetFinalPath(apiPath);
if (finalPath != null)
{
return finalPath;
}
var longPath = TryGetLongPath(fullPath) ?? TryGetLongPath(apiPath) ?? fullPath;
return RemoveExtendedPathPrefix(longPath);
}
private static string AddExtendedPathPrefix(string path)
{
const string extendedPathPrefix = @"\\?\";
const string devicePathPrefix = @"\\.\";
if (path.StartsWith(extendedPathPrefix, StringComparison.Ordinal) ||
path.StartsWith(devicePathPrefix, StringComparison.Ordinal))
{
return path;
}
if (path.StartsWith(@"\\", StringComparison.Ordinal))
{
return string.Concat(@"\\?\UNC\", path.AsSpan(2));
}
return path.Length >= 3 &&
char.IsAsciiLetter(path[0]) &&
path[1] == ':' &&
path[2] == '\\'
? extendedPathPrefix + path
: path;
}
private static string TryGetLongPath(string path)
{
var buffer = new StringBuilder(path.Length + 1);
while (true)
{
var length = GetLongPathNameW(path, buffer, (uint)buffer.Capacity);
if (length == 0)
{
return null;
}
if (length < buffer.Capacity)
{
return buffer.ToString();
}
buffer.EnsureCapacity(checked((int)length));
}
}
private static string TryGetFinalPath(string path)
{
const uint fileShareRead = 0x00000001;
const uint fileShareWrite = 0x00000002;
const uint fileShareDelete = 0x00000004;
const uint openExisting = 3;
const uint fileFlagOpenReparsePoint = 0x00200000;
const uint volumeNameNt = 0x00000002;
// Resolve parent-directory aliases and filesystem casing while preserving the
// final directory entry (for example, a hard link or symbolic link) as distinct.
using SafeFileHandle handle = CreateFileW(
path,
0,
fileShareRead | fileShareWrite | fileShareDelete,
IntPtr.Zero,
openExisting,
fileFlagOpenReparsePoint,
IntPtr.Zero);
if (handle.IsInvalid)
{
return null;
}
var buffer = new StringBuilder(path.Length + 1);
while (true)
{
var length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, volumeNameNt);
if (length == 0)
{
return null;
}
if (length < buffer.Capacity)
{
return buffer.ToString();
}
buffer.EnsureCapacity(checked((int)length));
}
}
private static string RemoveExtendedPathPrefix(string path)
{
const string extendedUncPrefix = @"\\?\UNC\";
const string extendedPathPrefix = @"\\?\";
if (path.StartsWith(extendedUncPrefix, StringComparison.OrdinalIgnoreCase))
{
return string.Concat(@"\\", path.AsSpan(extendedUncPrefix.Length));
}
if (path.Length >= extendedPathPrefix.Length + 3 &&
path.StartsWith(extendedPathPrefix, StringComparison.OrdinalIgnoreCase) &&
char.IsAsciiLetter(path[extendedPathPrefix.Length]) &&
path[extendedPathPrefix.Length + 1] == ':' &&
path[extendedPathPrefix.Length + 2] == '\\')
{
path = path.Substring(extendedPathPrefix.Length);
}
return path.Length >= 2 && path[1] == ':'
? char.ToUpperInvariant(path[0]) + path.Substring(1)
: path;
}
private static void AddInputError(ResizeBatch batch, string input, string message)
=> batch._inputErrors.Add(new ResizeError(input, message));
private static bool ContainsWildcard(string value)
{
var startIndex = value.StartsWith(@"\\?\", StringComparison.Ordinal) ? 4 : 0;
return value.IndexOf('*', startIndex) >= 0 || value.IndexOf('?', startIndex) >= 0;
}
public static ResizeBatch FromCommandLine(TextReader standardInput, string[] args)
{
var options = CliOptions.Parse(args);
@@ -151,7 +406,7 @@ namespace ImageResizer.Models
{
double total = Files.Count;
int completed = 0;
var errors = new ConcurrentBag<ResizeError>();
var processingErrors = new ConcurrentBag<ResizeError>();
await Parallel.ForEachAsync(
Files,
@@ -167,14 +422,28 @@ namespace ImageResizer.Models
}
catch (Exception ex)
{
errors.Add(new ResizeError(_fileSystem.Path.GetFileName(file), ex.Message));
processingErrors.Add(new ResizeError(_fileSystem.Path.GetFileName(file), FormatErrorMessage(ex)));
}
Interlocked.Increment(ref completed);
reportProgress(completed, total);
});
return errors;
return _inputErrors.Concat(processingErrors);
}
internal static string FormatErrorMessage(Exception exception)
{
if (!string.IsNullOrWhiteSpace(exception.Message))
{
return exception.Message;
}
return string.Format(
CultureInfo.InvariantCulture,
Resources.CLI_ErrorProcessingFallback,
exception.GetType().Name,
exception.HResult);
}
protected virtual async Task ExecuteAsync(string file, Settings settings)
@@ -182,5 +451,28 @@ namespace ImageResizer.Models
var aiService = _aiSuperResolutionService ?? NoOpAiSuperResolutionService.Instance;
await new ResizeOperation(file, DestinationDirectory, settings, aiService).ExecuteAsync();
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
private static extern SafeFileHandle CreateFileW(
string fileName,
uint desiredAccess,
uint shareMode,
IntPtr securityAttributes,
uint creationDisposition,
uint flagsAndAttributes,
IntPtr templateFile);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
private static extern uint GetFinalPathNameByHandleW(
SafeFileHandle fileHandle,
StringBuilder filePath,
uint filePathLength,
uint flags);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, ExactSpelling = true, SetLastError = true)]
private static extern uint GetLongPathNameW(
string shortPath,
StringBuilder longPath,
uint bufferLength);
}
}

View File

@@ -62,6 +62,9 @@ namespace ImageResizer.Models
public async Task ExecuteAsync()
{
var originalLastWriteTimeUtc = _settings.KeepDateModified
? _fileSystem.File.GetLastWriteTimeUtc(_file)
: (DateTime?)null;
string path;
using (var inputStream = _fileSystem.File.OpenRead(_file))
@@ -143,15 +146,21 @@ namespace ImageResizer.Models
}
}
if (_settings.KeepDateModified)
{
_fileSystem.File.SetLastWriteTimeUtc(path, _fileSystem.File.GetLastWriteTimeUtc(_file));
}
string backup = null;
if (_settings.Replace)
{
var backup = GetBackupPath();
backup = GetBackupPath();
_fileSystem.File.Replace(path, _file, backup, ignoreMetadataErrors: true);
path = _file;
}
if (originalLastWriteTimeUtc.HasValue)
{
_fileSystem.File.SetLastWriteTimeUtc(path, originalLastWriteTimeUtc.Value);
}
if (backup != null)
{
FileSystem.DeleteFile(backup, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin);
}
}
@@ -476,30 +485,46 @@ namespace ImageResizer.Models
}
// Calculate scaled dimensions
uint scaledWidth = (uint)Math.Max(1, (int)Math.Round(originalWidth * scaleX));
uint scaledHeight = (uint)Math.Max(1, (int)Math.Round(originalHeight * scaleY));
uint scaledWidth = GetValidatedScaledDimension(originalWidth * scaleX);
uint scaledHeight = GetValidatedScaledDimension(originalHeight * scaleY);
// Apply the centered crop for Fill mode, if necessary.
if (_settings.SelectedSize.Fit == ResizeFit.Fill
&& (scaledWidth > (uint)width || scaledHeight > (uint)height))
if (_settings.SelectedSize.Fit == ResizeFit.Fill)
{
uint cropX = (uint)(((originalWidth * scaleX) - width) / 2);
uint cropY = (uint)(((originalHeight * scaleY) - height) / 2);
uint targetWidth = GetValidatedScaledDimension(width);
uint targetHeight = GetValidatedScaledDimension(height);
var cropBounds = new BitmapBounds
if (scaledWidth > targetWidth || scaledHeight > targetHeight)
{
X = cropX,
Y = cropY,
Width = (uint)width,
Height = (uint)height,
};
uint cropX = (scaledWidth - targetWidth) / 2;
uint cropY = (scaledHeight - targetHeight) / 2;
return (scaledWidth, scaledHeight, cropBounds, false);
var cropBounds = new BitmapBounds
{
X = cropX,
Y = cropY,
Width = targetWidth,
Height = targetHeight,
};
return (scaledWidth, scaledHeight, cropBounds, false);
}
}
return (scaledWidth, scaledHeight, null, false);
}
private static uint GetValidatedScaledDimension(double value)
{
if (!double.IsFinite(value) || value < 0 || value > int.MaxValue)
{
throw new ArgumentOutOfRangeException(nameof(value), Resources.Error_DimensionOutOfRange);
}
var roundedValue = Math.Round(value);
return (uint)Math.Max(1, (int)roundedValue);
}
private async Task<BitmapEncoder> CreateFreshEncoderAsync(Guid encoderGuid, IRandomAccessStream outputStream)
{
var propertySet = GetEncoderPropertySet(encoderGuid);

View File

@@ -2,6 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using ImageResizer.Helpers;
namespace ImageResizer.Properties
@@ -71,5 +73,59 @@ namespace ImageResizer.Properties
public static string CLI_WarningInvalidSizeIndex => ResourceLoaderInstance.GetString("CLI_WarningInvalidSizeIndex");
public static string CLI_NoInputFiles => ResourceLoaderInstance.GetString("CLI_NoInputFiles");
public static string CLI_ErrorUnknownOption => GetStringOrDefault(
"CLI_ErrorUnknownOption",
"Unrecognized option '{0}'. Use '--' before a file name that starts with '-', or prefix it with '.\\'.");
public static string CLI_ErrorInvalidDimension => GetStringOrDefault(
"CLI_ErrorInvalidDimension",
"Width and height must be finite numbers greater than or equal to zero.");
public static string CLI_ErrorZeroDimensions => GetStringOrDefault(
"CLI_ErrorZeroDimensions",
"Width and height cannot both be zero for a custom size.");
public static string CLI_ErrorPercentWidthRequired => GetStringOrDefault(
"CLI_ErrorPercentWidthRequired",
"A positive width is required for percentage-based Fit and Fill sizes.");
public static string CLI_ErrorSizeIndexOutOfRange => GetStringOrDefault(
"CLI_ErrorSizeIndexOutOfRange",
"Size index {0} is out of range. The maximum valid index is {1}.");
public static string Error_DimensionOutOfRange => GetStringOrDefault(
"Error_DimensionOutOfRange",
"The requested output dimensions are outside the supported range.");
public static string CLI_ErrorFileNotFound => GetStringOrDefault("CLI_ErrorFileNotFound", "Input file not found.");
public static string CLI_ErrorUnsupportedFileType => GetStringOrDefault("CLI_ErrorUnsupportedFileType", "Unsupported image file type.");
public static string CLI_ErrorInvalidInputPath => GetStringOrDefault("CLI_ErrorInvalidInputPath", "Invalid input path: {0}");
public static string CLI_ErrorWildcardInDirectory => GetStringOrDefault(
"CLI_ErrorWildcardInDirectory",
"Wildcards are supported only in the file name portion of a path.");
public static string CLI_ErrorNoWildcardMatches => GetStringOrDefault(
"CLI_ErrorNoWildcardMatches",
"No files matched the wildcard pattern.");
public static string CLI_ErrorProcessingFallback => GetStringOrDefault(
"CLI_ErrorProcessingFallback",
"Image processing failed with {0} (HRESULT 0x{1:X8}).");
public static string CLI_WarningShrinkOnlyPercent => GetStringOrDefault(
"CLI_WarningShrinkOnlyPercent",
"Warning: Shrink-only is ignored for percentage-based sizes.");
private static string GetStringOrDefault(string key, string defaultValue)
{
var value = ResourceLoaderInstance.GetString(key);
return string.IsNullOrWhiteSpace(value) || string.Equals(value, key, StringComparison.Ordinal)
? defaultValue
: value;
}
}
}

View File

@@ -302,6 +302,45 @@
<data name="CLI_NoInputFiles" xml:space="preserve">
<value>No input files or pipe specified. Showing usage.</value>
</data>
<data name="CLI_ErrorUnknownOption" xml:space="preserve">
<value>Unrecognized option '{0}'. Use '--' before a file name that starts with '-', or prefix it with '.\'.</value>
</data>
<data name="CLI_ErrorInvalidDimension" xml:space="preserve">
<value>Width and height must be finite numbers greater than or equal to zero.</value>
</data>
<data name="CLI_ErrorZeroDimensions" xml:space="preserve">
<value>Width and height cannot both be zero for a custom size.</value>
</data>
<data name="CLI_ErrorPercentWidthRequired" xml:space="preserve">
<value>A positive width is required for percentage-based Fit and Fill sizes.</value>
</data>
<data name="CLI_ErrorSizeIndexOutOfRange" xml:space="preserve">
<value>Size index {0} is out of range. The maximum valid index is {1}.</value>
</data>
<data name="Error_DimensionOutOfRange" xml:space="preserve">
<value>The requested output dimensions are outside the supported range.</value>
</data>
<data name="CLI_ErrorFileNotFound" xml:space="preserve">
<value>Input file not found.</value>
</data>
<data name="CLI_ErrorUnsupportedFileType" xml:space="preserve">
<value>Unsupported image file type.</value>
</data>
<data name="CLI_ErrorInvalidInputPath" xml:space="preserve">
<value>Invalid input path: {0}</value>
</data>
<data name="CLI_ErrorWildcardInDirectory" xml:space="preserve">
<value>Wildcards are supported only in the file name portion of a path.</value>
</data>
<data name="CLI_ErrorNoWildcardMatches" xml:space="preserve">
<value>No files matched the wildcard pattern.</value>
</data>
<data name="CLI_ErrorProcessingFallback" xml:space="preserve">
<value>Image processing failed with {0} (HRESULT 0x{1:X8}).</value>
</data>
<data name="CLI_WarningShrinkOnlyPercent" xml:space="preserve">
<value>Warning: Shrink-only is ignored for percentage-based sizes.</value>
</data>
<data name="CLI_ConfigTitle" xml:space="preserve">
<value>ImageResizer - Current Configuration</value>
</data>
@@ -375,7 +414,7 @@
<value> PowerToys.ImageResizerCLI.exe --width 800 --height 600 image.jpg</value>
</data>
<data name="CLI_UsageExamplePercent" xml:space="preserve">
<value> PowerToys.ImageResizerCLI.exe -w 50 -h 50 -u Percent *.jpg</value>
<value> PowerToys.ImageResizerCLI.exe -w 50 -h 50 -u Percent "*.jpg"</value>
</data>
<data name="CLI_UsageExamplePreset" xml:space="preserve">
<value> PowerToys.ImageResizerCLI.exe --size 0 -d "C:\Output" photo.png</value>
@@ -387,7 +426,7 @@
<value>Set output filename format (%1=original name, %2=size name)</value>
</data>
<data name="CLI_Option_Files" xml:space="preserve">
<value>Image files to resize</value>
<value>Image files to resize (wildcards are supported in file names)</value>
</data>
<data name="CLI_Option_Fit" xml:space="preserve">
<value>Set fit mode (Fill, Fit, Stretch)</value>
@@ -414,7 +453,7 @@
<value>Show current configuration</value>
</data>
<data name="CLI_Option_ShrinkOnly" xml:space="preserve">
<value>Only shrink images, don't enlarge</value>
<value>Only shrink images for non-percentage sizes</value>
</data>
<data name="CLI_Option_RemoveMetadata" xml:space="preserve">
<value>Remove metadata from resized images</value>
@@ -437,4 +476,4 @@
<data name="Resize_button.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Resize</value>
</data>
</root>
</root>