diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json
index 1b4325ba1a..b417597184 100644
--- a/.pipelines/ESRPSigning_core.json
+++ b/.pipelines/ESRPSigning_core.json
@@ -131,6 +131,8 @@
"PowerToys.ImageResizer.exe",
"PowerToys.ImageResizer.dll",
+ "WinUI3Apps\\PowerToys.ImageResizerCLI.exe",
+ "WinUI3Apps\\PowerToys.ImageResizerCLI.dll",
"PowerToys.ImageResizerExt.dll",
"PowerToys.ImageResizerContextMenu.dll",
"ImageResizerContextMenuPackage.msix",
diff --git a/PowerToys.slnx b/PowerToys.slnx
index bc481ce526..8bcadb8f1c 100644
--- a/PowerToys.slnx
+++ b/PowerToys.slnx
@@ -459,6 +459,10 @@
+
+
+
+
diff --git a/doc/devdocs/cli-conventions.md b/doc/devdocs/cli-conventions.md
new file mode 100644
index 0000000000..a5bc4ec04b
--- /dev/null
+++ b/doc/devdocs/cli-conventions.md
@@ -0,0 +1,93 @@
+# CLI Conventions
+
+This document describes the conventions for implementing command-line interfaces (CLI) in PowerToys modules.
+
+## Library
+
+Use the **System.CommandLine** library for CLI argument parsing. This is already defined in `Directory.Packages.props`:
+
+```xml
+
+```
+
+Add the reference to your project:
+
+```xml
+
+```
+
+## Option Naming and Definition
+
+- Use `--kebab-case` for long form (e.g., `--shrink-only`).
+- Use single `-x` for short form (e.g., `-s`, `-w`).
+- Define aliases as static readonly arrays: `["--silent", "-s"]`.
+- Create options using `Option` with descriptive help text.
+- Add validators for options that require range or format checking.
+
+## RootCommand Setup
+
+- Create a `RootCommand` with a brief description.
+- Add all options and arguments to the command.
+
+## Parsing
+
+- Use `Parser(rootCommand).Parse(args)` to parse CLI arguments.
+- Extract option values using `parseResult.GetValueForOption()`.
+- Note: Use `Parser` directly; `RootCommand.Parse()` may not be available with the pinned System.CommandLine version.
+
+### Parse/Validation Errors
+
+- On parse/validation errors, print error messages and usage, then exit with non-zero code.
+
+## Examples
+
+Reference implementations:
+- Awake: `src/modules/Awake/Awake/Program.cs`
+- ImageResizer: `src/modules/imageresizer/ui/Cli/`
+
+## Help Output
+
+- Provide a `PrintUsage()` method for custom help formatting if needed.
+
+## Best Practices
+
+1. **Consistency**: Follow existing module patterns.
+2. **Documentation**: Always provide help text for each option.
+3. **Validation**: Validate input and provide clear error messages.
+4. **Atomicity**: Make one logical change per PR; avoid drive-by refactors.
+5. **Build/Test Discipline**: Build and test synchronously, one terminal per operation.
+6. **Style**: Follow repo analyzers (`.editorconfig`, StyleCop) and formatting rules.
+
+## Logging Requirements
+
+- Use `ManagedCommon.Logger` for consistent logging.
+- Initialize logging early in `Main()`.
+- Use dual output (console + log file) for errors and warnings to ensure visibility.
+- Reference: `src/modules/imageresizer/ui/Cli/CliLogger.cs`
+
+## Error Handling
+
+### Exit Codes
+
+- `0`: Success
+- `1`: General error (parsing, validation, runtime)
+- `2`: Invalid arguments (optional)
+
+### Exception Handling
+
+- Always wrap `Main()` in try-catch for unhandled exceptions.
+- Log exceptions before exiting with non-zero code.
+- Display user-friendly error messages to stderr.
+- Preserve detailed stack traces in log files only.
+
+## Testing Requirements
+
+- Include tests for argument parsing, validation, and edge cases.
+- Place CLI tests in module-specific test projects (e.g., `src/modules/[module]/tests/*CliTests.cs`).
+
+## Signing and Deployment
+
+- CLI executables are signed automatically in CI/CD.
+- **New CLI tools**: Add your executable and dll to `.pipelines/ESRPSigning_core.json` in the signing list.
+- CLI executables are deployed alongside their parent module (e.g., `C:\Program Files\PowerToys\modules\[ModuleName]\`).
+- Use self-contained deployment (import `Common.SelfContained.props`).
diff --git a/src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj b/src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj
new file mode 100644
index 0000000000..41de902d6e
--- /dev/null
+++ b/src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+ PowerToys.ImageResizerCLI
+ PowerToys Image Resizer Command Line Interface
+ PowerToys Image Resizer CLI
+ Exe
+ x64;ARM64
+ false
+ false
+ ..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\
+ PowerToys.ImageResizerCLI
+ $(NoWarn);SA1500;SA1402;CA1852
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/imageresizer/ImageResizerCLI/Program.cs b/src/modules/imageresizer/ImageResizerCLI/Program.cs
new file mode 100644
index 0000000000..4716730995
--- /dev/null
+++ b/src/modules/imageresizer/ImageResizerCLI/Program.cs
@@ -0,0 +1,50 @@
+// 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.Globalization;
+using System.Text;
+
+using ImageResizer.Cli;
+using ManagedCommon;
+
+namespace ImageResizerCLI;
+
+internal static class Program
+{
+ private static int Main(string[] args)
+ {
+ try
+ {
+ string appLanguage = LanguageHelper.LoadLanguage();
+ if (!string.IsNullOrEmpty(appLanguage))
+ {
+ System.Threading.Thread.CurrentThread.CurrentUICulture = new CultureInfo(appLanguage);
+ }
+ }
+ catch (CultureNotFoundException)
+ {
+ // Ignore invalid culture and fall back to default.
+ }
+
+ Console.InputEncoding = Encoding.Unicode;
+
+ // Initialize logger to file (same as other modules)
+ CliLogger.Initialize("\\ImageResizer\\Logs");
+ CliLogger.Info($"ImageResizerCLI started with {args.Length} argument(s)");
+
+ try
+ {
+ var executor = new ImageResizerCliExecutor();
+ return executor.Run(args);
+ }
+ catch (Exception ex)
+ {
+ CliLogger.Error($"Unhandled exception: {ex.Message}");
+ CliLogger.Error($"Stack trace: {ex.StackTrace}");
+ Console.Error.WriteLine($"Fatal error: {ex.Message}");
+ return 1;
+ }
+ }
+}
diff --git a/src/modules/imageresizer/tests/Cli/CliSettingsApplierTests.cs b/src/modules/imageresizer/tests/Cli/CliSettingsApplierTests.cs
new file mode 100644
index 0000000000..20169bff7f
--- /dev/null
+++ b/src/modules/imageresizer/tests/Cli/CliSettingsApplierTests.cs
@@ -0,0 +1,320 @@
+// 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 ImageResizer.Cli;
+using ImageResizer.Models;
+using ImageResizer.Properties;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace ImageResizer.Tests.Cli
+{
+ [TestClass]
+ public class CliSettingsApplierTests
+ {
+ private Settings CreateDefaultSettings()
+ {
+ var settings = new Settings();
+ settings.Sizes.Add(new ResizeSize(0, "Small", ResizeFit.Fit, 854, 480, ResizeUnit.Pixel));
+ settings.Sizes.Add(new ResizeSize(1, "Medium", ResizeFit.Fit, 1366, 768, ResizeUnit.Pixel));
+ settings.Sizes.Add(new ResizeSize(2, "Large", ResizeFit.Fit, 1920, 1080, ResizeUnit.Pixel));
+ return settings;
+ }
+
+ [TestMethod]
+ public void Apply_WithCustomWidth_SetsCustomSizeWidth()
+ {
+ var options = new CliOptions { Width = 800 };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual(800.0, settings.CustomSize.Width);
+ }
+
+ [TestMethod]
+ public void Apply_WithCustomHeight_SetsCustomSizeHeight()
+ {
+ var options = new CliOptions { Height = 600 };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual(600.0, settings.CustomSize.Height);
+ }
+
+ [TestMethod]
+ public void Apply_WithCustomSize_SelectsCustomSizeIndex()
+ {
+ var options = new CliOptions { Width = 800, Height = 600 };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ // Custom size index should be settings.Sizes.Count
+ Assert.AreEqual(settings.Sizes.Count, settings.SelectedSizeIndex);
+ }
+
+ [TestMethod]
+ public void Apply_WithZeroWidth_SetsZeroForAutoCalculation()
+ {
+ var options = new CliOptions { Width = 0, Height = 600 };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual(0.0, settings.CustomSize.Width);
+ Assert.AreEqual(600.0, settings.CustomSize.Height);
+ }
+
+ [TestMethod]
+ public void Apply_WithZeroHeight_SetsZeroForAutoCalculation()
+ {
+ var options = new CliOptions { Width = 800, Height = 0 };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual(800.0, settings.CustomSize.Width);
+ Assert.AreEqual(0.0, settings.CustomSize.Height);
+ }
+
+ [TestMethod]
+ public void Apply_WithNullWidthAndHeight_DoesNotModifyCustomSize()
+ {
+ var options = new CliOptions { Width = null, Height = null };
+ var settings = CreateDefaultSettings();
+ var originalWidth = settings.CustomSize.Width;
+ var originalHeight = settings.CustomSize.Height;
+
+ CliSettingsApplier.Apply(options, settings);
+
+ // When both null, should not modify CustomSize (keeps default 1024x640)
+ Assert.AreEqual(originalWidth, settings.CustomSize.Width);
+ Assert.AreEqual(originalHeight, settings.CustomSize.Height);
+ }
+
+ [TestMethod]
+ public void Apply_WithUnit_SetsCustomSizeUnit()
+ {
+ var options = new CliOptions { Width = 100, Unit = ResizeUnit.Percent };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual(ResizeUnit.Percent, settings.CustomSize.Unit);
+ }
+
+ [TestMethod]
+ public void Apply_WithFit_SetsCustomSizeFit()
+ {
+ var options = new CliOptions { Width = 800, Fit = ResizeFit.Fill };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual(ResizeFit.Fill, settings.CustomSize.Fit);
+ }
+
+ [TestMethod]
+ public void Apply_WithValidSizeIndex_SetsSelectedSizeIndex()
+ {
+ var options = new CliOptions { SizeIndex = 1 };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual(1, settings.SelectedSizeIndex);
+ }
+
+ [TestMethod]
+ public void Apply_WithInvalidSizeIndex_DoesNotChangeSelection()
+ {
+ var options = new CliOptions { SizeIndex = 99 };
+ var settings = CreateDefaultSettings();
+ var originalIndex = settings.SelectedSizeIndex;
+
+ CliSettingsApplier.Apply(options, settings);
+
+ // Should remain unchanged when invalid
+ Assert.AreEqual(originalIndex, settings.SelectedSizeIndex);
+ }
+
+ [TestMethod]
+ public void Apply_WithNegativeSizeIndex_DoesNotChangeSelection()
+ {
+ var options = new CliOptions { SizeIndex = -1 };
+ var settings = CreateDefaultSettings();
+ var originalIndex = settings.SelectedSizeIndex;
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual(originalIndex, settings.SelectedSizeIndex);
+ }
+
+ [TestMethod]
+ public void Apply_WithShrinkOnly_SetsShrinkOnly()
+ {
+ var options = new CliOptions { ShrinkOnly = true };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.IsTrue(settings.ShrinkOnly);
+ }
+
+ [TestMethod]
+ public void Apply_WithReplace_SetsReplace()
+ {
+ var options = new CliOptions { Replace = true };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.IsTrue(settings.Replace);
+ }
+
+ [TestMethod]
+ public void Apply_WithIgnoreOrientation_SetsIgnoreOrientation()
+ {
+ var options = new CliOptions { IgnoreOrientation = true };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.IsTrue(settings.IgnoreOrientation);
+ }
+
+ [TestMethod]
+ public void Apply_WithRemoveMetadata_SetsRemoveMetadata()
+ {
+ var options = new CliOptions { RemoveMetadata = true };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.IsTrue(settings.RemoveMetadata);
+ }
+
+ [TestMethod]
+ public void Apply_WithJpegQualityLevel_SetsJpegQualityLevel()
+ {
+ var options = new CliOptions { JpegQualityLevel = 85 };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual(85, settings.JpegQualityLevel);
+ }
+
+ [TestMethod]
+ public void Apply_WithKeepDateModified_SetsKeepDateModified()
+ {
+ var options = new CliOptions { KeepDateModified = true };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.IsTrue(settings.KeepDateModified);
+ }
+
+ [TestMethod]
+ public void Apply_WithFileName_SetsFileName()
+ {
+ var options = new CliOptions { FileName = "%1 (%2)" };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual("%1 (%2)", settings.FileName);
+ }
+
+ [TestMethod]
+ public void Apply_WithEmptyFileName_DoesNotChangeFileName()
+ {
+ var options = new CliOptions { FileName = string.Empty };
+ var settings = CreateDefaultSettings();
+ var originalFileName = settings.FileName;
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual(originalFileName, settings.FileName);
+ }
+
+ [TestMethod]
+ public void Apply_WithMultipleOptions_AppliesAllOptions()
+ {
+ var options = new CliOptions
+ {
+ Width = 800,
+ Height = 600,
+ Unit = ResizeUnit.Percent,
+ Fit = ResizeFit.Fill,
+ ShrinkOnly = true,
+ Replace = true,
+ IgnoreOrientation = true,
+ RemoveMetadata = true,
+ JpegQualityLevel = 90,
+ KeepDateModified = true,
+ FileName = "test_%2",
+ };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual(800.0, settings.CustomSize.Width);
+ Assert.AreEqual(600.0, settings.CustomSize.Height);
+ Assert.AreEqual(ResizeUnit.Percent, settings.CustomSize.Unit);
+ Assert.AreEqual(ResizeFit.Fill, settings.CustomSize.Fit);
+ Assert.IsTrue(settings.ShrinkOnly);
+ Assert.IsTrue(settings.Replace);
+ Assert.IsTrue(settings.IgnoreOrientation);
+ Assert.IsTrue(settings.RemoveMetadata);
+ Assert.AreEqual(90, settings.JpegQualityLevel);
+ Assert.IsTrue(settings.KeepDateModified);
+ Assert.AreEqual("test_%2", settings.FileName);
+ }
+
+ [TestMethod]
+ public void Apply_CustomSizeTakesPrecedence_OverSizeIndex()
+ {
+ var options = new CliOptions
+ {
+ Width = 800,
+ Height = 600,
+ SizeIndex = 1, // Should be ignored when Width/Height specified
+ };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ // Custom size should be selected, not preset
+ Assert.AreEqual(settings.Sizes.Count, settings.SelectedSizeIndex);
+ Assert.AreEqual(800.0, settings.CustomSize.Width);
+ }
+
+ [TestMethod]
+ public void Apply_WithOnlyWidth_StillSelectsCustomSize()
+ {
+ var options = new CliOptions { Width = 800 };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual(settings.Sizes.Count, settings.SelectedSizeIndex);
+ Assert.AreEqual(800.0, settings.CustomSize.Width);
+ }
+
+ [TestMethod]
+ public void Apply_WithOnlyHeight_StillSelectsCustomSize()
+ {
+ var options = new CliOptions { Height = 600 };
+ var settings = CreateDefaultSettings();
+
+ CliSettingsApplier.Apply(options, settings);
+
+ Assert.AreEqual(settings.Sizes.Count, settings.SelectedSizeIndex);
+ Assert.AreEqual(600.0, settings.CustomSize.Height);
+ }
+ }
+}
diff --git a/src/modules/imageresizer/tests/Models/CliOptionsTests.cs b/src/modules/imageresizer/tests/Models/CliOptionsTests.cs
new file mode 100644
index 0000000000..3c88a100ba
--- /dev/null
+++ b/src/modules/imageresizer/tests/Models/CliOptionsTests.cs
@@ -0,0 +1,268 @@
+// 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.Linq;
+using ImageResizer.Cli.Commands;
+using ImageResizer.Models;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace ImageResizer.Tests.Models
+{
+ [TestClass]
+ public class CliOptionsTests
+ {
+ private static readonly string[] _multiFileArgs = new[] { "test1.jpg", "test2.jpg", "test3.jpg" };
+ private static readonly string[] _mixedOptionsArgs = new[] { "--width", "800", "test1.jpg", "--height", "600", "test2.jpg" };
+
+ [TestMethod]
+ public void Parse_WithValidWidth_SetsWidth()
+ {
+ var args = new[] { "--width", "800", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(800.0, options.Width);
+ }
+
+ [TestMethod]
+ public void Parse_WithValidHeight_SetsHeight()
+ {
+ var args = new[] { "--height", "600", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(600.0, options.Height);
+ }
+
+ [TestMethod]
+ public void Parse_WithShortWidthAlias_WorksIdentically()
+ {
+ var longFormArgs = new[] { "--width", "800", "test.jpg" };
+ var shortFormArgs = new[] { "-w", "800", "test.jpg" };
+ var longForm = CliOptions.Parse(longFormArgs);
+ var shortForm = CliOptions.Parse(shortFormArgs);
+
+ Assert.AreEqual(longForm.Width, shortForm.Width);
+ }
+
+ [TestMethod]
+ public void Parse_WithShortHeightAlias_WorksIdentically()
+ {
+ var longFormArgs = new[] { "--height", "600", "test.jpg" };
+ var shortFormArgs = new[] { "-h", "600", "test.jpg" };
+ var longForm = CliOptions.Parse(longFormArgs);
+ var shortForm = CliOptions.Parse(shortFormArgs);
+
+ Assert.AreEqual(longForm.Height, shortForm.Height);
+ }
+
+ [TestMethod]
+ public void Parse_WithValidUnit_SetsUnit()
+ {
+ var args = new[] { "--unit", "Percent", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(ResizeUnit.Percent, options.Unit);
+ }
+
+ [TestMethod]
+ public void Parse_WithValidFit_SetsFit()
+ {
+ var args = new[] { "--fit", "Fill", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(ResizeFit.Fill, options.Fit);
+ }
+
+ [TestMethod]
+ public void Parse_WithSizeIndex_SetsSizeIndex()
+ {
+ var args = new[] { "--size", "2", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(2, options.SizeIndex);
+ }
+
+ [TestMethod]
+ public void Parse_WithShrinkOnly_SetsShrinkOnly()
+ {
+ var args = new[] { "--shrink-only", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(true, options.ShrinkOnly);
+ }
+
+ [TestMethod]
+ public void Parse_WithReplace_SetsReplace()
+ {
+ var args = new[] { "--replace", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(true, options.Replace);
+ }
+
+ [TestMethod]
+ public void Parse_WithIgnoreOrientation_SetsIgnoreOrientation()
+ {
+ var args = new[] { "--ignore-orientation", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(true, options.IgnoreOrientation);
+ }
+
+ [TestMethod]
+ public void Parse_WithRemoveMetadata_SetsRemoveMetadata()
+ {
+ var args = new[] { "--remove-metadata", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(true, options.RemoveMetadata);
+ }
+
+ [TestMethod]
+ public void Parse_WithValidQuality_SetsQuality()
+ {
+ var args = new[] { "--quality", "85", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(85, options.JpegQualityLevel);
+ }
+
+ [TestMethod]
+ public void Parse_WithKeepDateModified_SetsKeepDateModified()
+ {
+ var args = new[] { "--keep-date-modified", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(true, options.KeepDateModified);
+ }
+
+ [TestMethod]
+ public void Parse_WithFileName_SetsFileName()
+ {
+ var args = new[] { "--filename", "%1 (%2)", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual("%1 (%2)", options.FileName);
+ }
+
+ [TestMethod]
+ public void Parse_WithDestination_SetsDestinationDirectory()
+ {
+ var args = new[] { "--destination", "C:\\Output", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual("C:\\Output", options.DestinationDirectory);
+ }
+
+ [TestMethod]
+ public void Parse_WithShortDestinationAlias_WorksIdentically()
+ {
+ var longFormArgs = new[] { "--destination", "C:\\Output", "test.jpg" };
+ var shortFormArgs = new[] { "-d", "C:\\Output", "test.jpg" };
+ var longForm = CliOptions.Parse(longFormArgs);
+ var shortForm = CliOptions.Parse(shortFormArgs);
+
+ Assert.AreEqual(longForm.DestinationDirectory, shortForm.DestinationDirectory);
+ }
+
+ [TestMethod]
+ public void Parse_WithProgressLines_SetsProgressLines()
+ {
+ var args = new[] { "--progress-lines", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(true, options.ProgressLines);
+ }
+
+ [TestMethod]
+ public void Parse_WithAccessibleAlias_SetsProgressLines()
+ {
+ var args = new[] { "--accessible", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(true, options.ProgressLines);
+ }
+
+ [TestMethod]
+ public void Parse_WithMultipleFiles_AddsAllFiles()
+ {
+ var args = _multiFileArgs;
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(3, options.Files.Count);
+ CollectionAssert.Contains(options.Files.ToList(), "test1.jpg");
+ CollectionAssert.Contains(options.Files.ToList(), "test2.jpg");
+ CollectionAssert.Contains(options.Files.ToList(), "test3.jpg");
+ }
+
+ [TestMethod]
+ public void Parse_WithMixedOptionsAndFiles_ParsesCorrectly()
+ {
+ var args = _mixedOptionsArgs;
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(800.0, options.Width);
+ Assert.AreEqual(600.0, options.Height);
+ Assert.AreEqual(2, options.Files.Count);
+ }
+
+ [TestMethod]
+ public void Parse_WithHelp_SetsShowHelp()
+ {
+ var args = new[] { "--help" };
+ var options = CliOptions.Parse(args);
+
+ Assert.IsTrue(options.ShowHelp);
+ }
+
+ [TestMethod]
+ public void Parse_WithShowConfig_SetsShowConfig()
+ {
+ var args = new[] { "--show-config" };
+ var options = CliOptions.Parse(args);
+
+ Assert.IsTrue(options.ShowConfig);
+ }
+
+ [TestMethod]
+ public void Parse_WithNoArguments_ReturnsEmptyOptions()
+ {
+ var args = Array.Empty();
+ var options = CliOptions.Parse(args);
+
+ Assert.IsNotNull(options);
+ Assert.AreEqual(0, options.Files.Count);
+ }
+
+ [TestMethod]
+ public void Parse_WithZeroWidth_AllowsZeroValue()
+ {
+ var args = new[] { "--width", "0", "--height", "600", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(0.0, options.Width);
+ Assert.AreEqual(600.0, options.Height);
+ }
+
+ [TestMethod]
+ public void Parse_WithZeroHeight_AllowsZeroValue()
+ {
+ var args = new[] { "--width", "800", "--height", "0", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(800.0, options.Width);
+ Assert.AreEqual(0.0, options.Height);
+ }
+
+ [TestMethod]
+ public void Parse_CaseInsensitiveEnums_ParsesCorrectly()
+ {
+ var args = new[] { "--unit", "pixel", "--fit", "fit", "test.jpg" };
+ var options = CliOptions.Parse(args);
+
+ Assert.AreEqual(ResizeUnit.Pixel, options.Unit);
+ Assert.AreEqual(ResizeFit.Fit, options.Fit);
+ }
+ }
+}
diff --git a/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs b/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs
index f45fc28e6a..bd6031cad4 100644
--- a/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs
+++ b/src/modules/imageresizer/tests/Models/ResizeBatchTests.cs
@@ -25,20 +25,27 @@ namespace ImageResizer.Models
[TestMethod]
public void FromCommandLineWorks()
{
+ // Use actual test files that exist in the test directory
+ var testDir = Path.GetDirectoryName(typeof(ResizeBatchTests).Assembly.Location);
+ var file1 = Path.Combine(testDir, "Test.jpg");
+ var file2 = Path.Combine(testDir, "Test.png");
+ var file3 = Path.Combine(testDir, "Test.gif");
+
var standardInput =
- "Image1.jpg" + EOL +
- "Image2.jpg";
+ file1 + EOL +
+ file2;
var args = new[]
{
"/d", "OutputDir",
- "Image3.jpg",
+ file3,
};
var result = ResizeBatch.FromCommandLine(
new StringReader(standardInput),
args);
- CollectionAssert.AreEquivalent(new List { "Image1.jpg", "Image2.jpg", "Image3.jpg" }, result.Files.ToArray());
+ var files = result.Files.Select(Path.GetFileName).ToArray();
+ CollectionAssert.AreEquivalent(new List { "Test.jpg", "Test.png", "Test.gif" }, files);
Assert.AreEqual("OutputDir", result.DestinationDirectory);
}
diff --git a/src/modules/imageresizer/ui/Cli/CliLogger.cs b/src/modules/imageresizer/ui/Cli/CliLogger.cs
new file mode 100644
index 0000000000..d497eee383
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/CliLogger.cs
@@ -0,0 +1,28 @@
+// 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 ManagedCommon;
+
+namespace ImageResizer.Cli
+{
+ public static class CliLogger
+ {
+ private static bool _initialized;
+
+ public static void Initialize(string logSubFolder)
+ {
+ if (!_initialized)
+ {
+ Logger.InitializeLogger(logSubFolder);
+ _initialized = true;
+ }
+ }
+
+ public static void Info(string message) => Logger.LogInfo(message);
+
+ public static void Warn(string message) => Logger.LogWarning(message);
+
+ public static void Error(string message) => Logger.LogError(message);
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/CliSettingsApplier.cs b/src/modules/imageresizer/ui/Cli/CliSettingsApplier.cs
new file mode 100644
index 0000000000..3cd15f8006
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/CliSettingsApplier.cs
@@ -0,0 +1,122 @@
+// 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.Globalization;
+
+using ImageResizer.Models;
+using ImageResizer.Properties;
+
+namespace ImageResizer.Cli
+{
+ ///
+ /// Applies CLI options to Settings object.
+ /// Separated from executor logic for Single Responsibility Principle.
+ ///
+ public static class CliSettingsApplier
+ {
+ ///
+ /// Applies CLI options to the settings, overriding default values.
+ ///
+ /// The CLI options to apply.
+ /// The settings to modify.
+ public static void Apply(CliOptions cliOptions, Settings settings)
+ {
+ // Handle complex size options first
+ ApplySizeOptions(cliOptions, settings);
+
+ // Apply simple property mappings
+ ApplySimpleOptions(cliOptions, settings);
+ }
+
+ private static void ApplySizeOptions(CliOptions cliOptions, Settings settings)
+ {
+ if (cliOptions.Width.HasValue || cliOptions.Height.HasValue)
+ {
+ ApplyCustomSizeOptions(cliOptions, settings);
+ }
+ else if (cliOptions.SizeIndex.HasValue)
+ {
+ ApplyPresetSizeOption(cliOptions, settings);
+ }
+ }
+
+ private static void ApplyCustomSizeOptions(CliOptions cliOptions, Settings settings)
+ {
+ // Set dimensions (0 = auto-calculate for aspect ratio preservation)
+ // Implementation: ResizeSize.ConvertToPixels() returns double.PositiveInfinity for 0 in Fit mode,
+ // causing Math.Min(scaleX, scaleY) to preserve aspect ratio by selecting the non-zero scale.
+ // For Fill/Stretch modes, 0 uses the original dimension instead.
+ settings.CustomSize.Width = cliOptions.Width ?? 0;
+ settings.CustomSize.Height = cliOptions.Height ?? 0;
+
+ // Apply optional properties
+ if (cliOptions.Unit.HasValue)
+ {
+ settings.CustomSize.Unit = cliOptions.Unit.Value;
+ }
+
+ if (cliOptions.Fit.HasValue)
+ {
+ settings.CustomSize.Fit = cliOptions.Fit.Value;
+ }
+
+ // Select custom size (index = Sizes.Count)
+ settings.SelectedSizeIndex = settings.Sizes.Count;
+ }
+
+ private static void ApplyPresetSizeOption(CliOptions cliOptions, Settings settings)
+ {
+ var index = cliOptions.SizeIndex.Value;
+
+ if (index >= 0 && index < settings.Sizes.Count)
+ {
+ settings.SelectedSizeIndex = index;
+ }
+ else
+ {
+ Console.Error.WriteLine(string.Format(CultureInfo.InvariantCulture, Resources.CLI_WarningInvalidSizeIndex, index));
+ CliLogger.Warn($"Invalid size index: {index}");
+ }
+ }
+
+ private static void ApplySimpleOptions(CliOptions cliOptions, Settings settings)
+ {
+ if (cliOptions.ShrinkOnly.HasValue)
+ {
+ settings.ShrinkOnly = cliOptions.ShrinkOnly.Value;
+ }
+
+ if (cliOptions.Replace.HasValue)
+ {
+ settings.Replace = cliOptions.Replace.Value;
+ }
+
+ if (cliOptions.IgnoreOrientation.HasValue)
+ {
+ settings.IgnoreOrientation = cliOptions.IgnoreOrientation.Value;
+ }
+
+ if (cliOptions.RemoveMetadata.HasValue)
+ {
+ settings.RemoveMetadata = cliOptions.RemoveMetadata.Value;
+ }
+
+ if (cliOptions.JpegQualityLevel.HasValue)
+ {
+ settings.JpegQualityLevel = cliOptions.JpegQualityLevel.Value;
+ }
+
+ if (cliOptions.KeepDateModified.HasValue)
+ {
+ settings.KeepDateModified = cliOptions.KeepDateModified.Value;
+ }
+
+ if (!string.IsNullOrEmpty(cliOptions.FileName))
+ {
+ settings.FileName = cliOptions.FileName;
+ }
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs b/src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs
new file mode 100644
index 0000000000..c63fb9cfdf
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs
@@ -0,0 +1,90 @@
+// 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.CommandLine;
+
+using ImageResizer.Cli.Options;
+
+namespace ImageResizer.Cli.Commands
+{
+ ///
+ /// Root command for the ImageResizer CLI.
+ ///
+ public sealed class ImageResizerRootCommand : RootCommand
+ {
+ public ImageResizerRootCommand()
+ : base("PowerToys Image Resizer - Resize images from command line")
+ {
+ HelpOption = new HelpOption();
+ ShowConfigOption = new ShowConfigOption();
+ DestinationOption = new DestinationOption();
+ WidthOption = new WidthOption();
+ HeightOption = new HeightOption();
+ UnitOption = new UnitOption();
+ FitOption = new FitOption();
+ SizeOption = new SizeOption();
+ ShrinkOnlyOption = new ShrinkOnlyOption();
+ ReplaceOption = new ReplaceOption();
+ IgnoreOrientationOption = new IgnoreOrientationOption();
+ RemoveMetadataOption = new RemoveMetadataOption();
+ QualityOption = new QualityOption();
+ KeepDateModifiedOption = new KeepDateModifiedOption();
+ FileNameOption = new FileNameOption();
+ ProgressLinesOption = new ProgressLinesOption();
+ FilesArgument = new FilesArgument();
+
+ AddOption(HelpOption);
+ AddOption(ShowConfigOption);
+ AddOption(DestinationOption);
+ AddOption(WidthOption);
+ AddOption(HeightOption);
+ AddOption(UnitOption);
+ AddOption(FitOption);
+ AddOption(SizeOption);
+ AddOption(ShrinkOnlyOption);
+ AddOption(ReplaceOption);
+ AddOption(IgnoreOrientationOption);
+ AddOption(RemoveMetadataOption);
+ AddOption(QualityOption);
+ AddOption(KeepDateModifiedOption);
+ AddOption(FileNameOption);
+ AddOption(ProgressLinesOption);
+ AddArgument(FilesArgument);
+ }
+
+ public HelpOption HelpOption { get; }
+
+ public ShowConfigOption ShowConfigOption { get; }
+
+ public DestinationOption DestinationOption { get; }
+
+ public WidthOption WidthOption { get; }
+
+ public HeightOption HeightOption { get; }
+
+ public UnitOption UnitOption { get; }
+
+ public FitOption FitOption { get; }
+
+ public SizeOption SizeOption { get; }
+
+ public ShrinkOnlyOption ShrinkOnlyOption { get; }
+
+ public ReplaceOption ReplaceOption { get; }
+
+ public IgnoreOrientationOption IgnoreOrientationOption { get; }
+
+ public RemoveMetadataOption RemoveMetadataOption { get; }
+
+ public QualityOption QualityOption { get; }
+
+ public KeepDateModifiedOption KeepDateModifiedOption { get; }
+
+ public FileNameOption FileNameOption { get; }
+
+ public ProgressLinesOption ProgressLinesOption { get; }
+
+ public FilesArgument FilesArgument { get; }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs b/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs
new file mode 100644
index 0000000000..bd22da62da
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs
@@ -0,0 +1,124 @@
+// 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.Globalization;
+using System.Linq;
+using System.Threading;
+
+using ImageResizer.Models;
+using ImageResizer.Properties;
+
+namespace ImageResizer.Cli
+{
+ ///
+ /// Executes Image Resizer CLI operations.
+ /// Instance-based design for better testability and Single Responsibility Principle.
+ ///
+ public class ImageResizerCliExecutor
+ {
+ ///
+ /// Runs the CLI executor with the provided command-line arguments.
+ ///
+ /// Command-line arguments.
+ /// Exit code.
+ public int Run(string[] args)
+ {
+ var cliOptions = CliOptions.Parse(args);
+
+ if (cliOptions.ParseErrors.Count > 0)
+ {
+ foreach (var error in cliOptions.ParseErrors)
+ {
+ Console.Error.WriteLine(error);
+ CliLogger.Error($"Parse error: {error}");
+ }
+
+ CliOptions.PrintUsage();
+ return 1;
+ }
+
+ if (cliOptions.ShowHelp)
+ {
+ CliOptions.PrintUsage();
+ return 0;
+ }
+
+ if (cliOptions.ShowConfig)
+ {
+ CliOptions.PrintConfig(Settings.Default);
+ return 0;
+ }
+
+ if (cliOptions.Files.Count == 0 && string.IsNullOrEmpty(cliOptions.PipeName))
+ {
+ Console.WriteLine(Resources.CLI_NoInputFiles);
+ CliOptions.PrintUsage();
+ return 1;
+ }
+
+ return RunSilentMode(cliOptions);
+ }
+
+ private int RunSilentMode(CliOptions cliOptions)
+ {
+ var batch = ResizeBatch.FromCliOptions(Console.In, cliOptions);
+ var settings = Settings.Default;
+ CliSettingsApplier.Apply(cliOptions, settings);
+
+ CliLogger.Info($"CLI mode: processing {batch.Files.Count} files");
+
+ // Use accessible line-based progress if requested or detected
+ bool useLineBasedProgress = cliOptions.ProgressLines ?? false;
+ int lastReportedMilestone = -1;
+
+ var errors = batch.Process(
+ (completed, total) =>
+ {
+ var progress = (int)((completed / total) * 100);
+
+ if (useLineBasedProgress)
+ {
+ // Milestone-based progress (0%, 25%, 50%, 75%, 100%)
+ int milestone = (progress / 25) * 25;
+ if (milestone > lastReportedMilestone || completed == (int)total)
+ {
+ lastReportedMilestone = milestone;
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Resources.CLI_ProgressFormat, progress, completed, (int)total));
+ }
+ }
+ else
+ {
+ // Traditional carriage return mode
+ Console.Write(string.Format(CultureInfo.InvariantCulture, "\r{0}", string.Format(CultureInfo.InvariantCulture, Resources.CLI_ProgressFormat, progress, completed, (int)total)));
+ }
+ },
+ settings,
+ CancellationToken.None);
+
+ if (!useLineBasedProgress)
+ {
+ Console.WriteLine();
+ }
+
+ var errorList = errors.ToList();
+ if (errorList.Count > 0)
+ {
+ Console.Error.WriteLine(string.Format(CultureInfo.InvariantCulture, Resources.CLI_CompletedWithErrors, errorList.Count));
+ CliLogger.Error($"Processing completed with {errorList.Count} error(s)");
+ foreach (var error in errorList)
+ {
+ Console.Error.WriteLine(string.Format(CultureInfo.InvariantCulture, " {0}: {1}", error.File, error.Error));
+ CliLogger.Error($" {error.File}: {error.Error}");
+ }
+
+ return 1;
+ }
+
+ CliLogger.Info("CLI batch completed successfully");
+ Console.WriteLine(Resources.CLI_AllFilesProcessed);
+ return 0;
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/DestinationOption.cs b/src/modules/imageresizer/ui/Cli/Options/DestinationOption.cs
new file mode 100644
index 0000000000..50a9e9bc10
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/DestinationOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class DestinationOption : Option
+ {
+ private static readonly string[] _aliases = ["--destination", "-d", "/d"];
+
+ public DestinationOption()
+ : base(_aliases, Properties.Resources.CLI_Option_Destination)
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/FileNameOption.cs b/src/modules/imageresizer/ui/Cli/Options/FileNameOption.cs
new file mode 100644
index 0000000000..fc1d7879db
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/FileNameOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class FileNameOption : Option
+ {
+ private static readonly string[] _aliases = ["--filename", "-n"];
+
+ public FileNameOption()
+ : base(_aliases, Properties.Resources.CLI_Option_FileName)
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/FilesArgument.cs b/src/modules/imageresizer/ui/Cli/Options/FilesArgument.cs
new file mode 100644
index 0000000000..e8d3c27872
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/FilesArgument.cs
@@ -0,0 +1,17 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class FilesArgument : Argument
+ {
+ public FilesArgument()
+ : base("files", Properties.Resources.CLI_Option_Files)
+ {
+ Arity = ArgumentArity.ZeroOrMore;
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/FitOption.cs b/src/modules/imageresizer/ui/Cli/Options/FitOption.cs
new file mode 100644
index 0000000000..65417f4fd0
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/FitOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class FitOption : Option
+ {
+ private static readonly string[] _aliases = ["--fit", "-f"];
+
+ public FitOption()
+ : base(_aliases, Properties.Resources.CLI_Option_Fit)
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/HeightOption.cs b/src/modules/imageresizer/ui/Cli/Options/HeightOption.cs
new file mode 100644
index 0000000000..7abbff7cf6
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/HeightOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class HeightOption : Option
+ {
+ private static readonly string[] _aliases = ["--height", "-h"];
+
+ public HeightOption()
+ : base(_aliases, Properties.Resources.CLI_Option_Height)
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/HelpOption.cs b/src/modules/imageresizer/ui/Cli/Options/HelpOption.cs
new file mode 100644
index 0000000000..ff42a22061
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/HelpOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class HelpOption : Option
+ {
+ private static readonly string[] _aliases = ["--help", "-?", "/?"];
+
+ public HelpOption()
+ : base(_aliases, Properties.Resources.CLI_Option_Help)
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/IgnoreOrientationOption.cs b/src/modules/imageresizer/ui/Cli/Options/IgnoreOrientationOption.cs
new file mode 100644
index 0000000000..35e7437d90
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/IgnoreOrientationOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class IgnoreOrientationOption : Option
+ {
+ private static readonly string[] _aliases = ["--ignore-orientation"];
+
+ public IgnoreOrientationOption()
+ : base(_aliases, Properties.Resources.CLI_Option_IgnoreOrientation)
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/KeepDateModifiedOption.cs b/src/modules/imageresizer/ui/Cli/Options/KeepDateModifiedOption.cs
new file mode 100644
index 0000000000..43b0977b82
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/KeepDateModifiedOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class KeepDateModifiedOption : Option
+ {
+ private static readonly string[] _aliases = ["--keep-date-modified"];
+
+ public KeepDateModifiedOption()
+ : base(_aliases, Properties.Resources.CLI_Option_KeepDateModified)
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/ProgressLinesOption.cs b/src/modules/imageresizer/ui/Cli/Options/ProgressLinesOption.cs
new file mode 100644
index 0000000000..95935fa0f1
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/ProgressLinesOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class ProgressLinesOption : Option
+ {
+ private static readonly string[] _aliases = ["--progress-lines", "--accessible"];
+
+ public ProgressLinesOption()
+ : base(_aliases, "Use line-based progress output for screen reader accessibility (milestones: 0%, 25%, 50%, 75%, 100%)")
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/QualityOption.cs b/src/modules/imageresizer/ui/Cli/Options/QualityOption.cs
new file mode 100644
index 0000000000..d87573ebfc
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/QualityOption.cs
@@ -0,0 +1,26 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class QualityOption : Option
+ {
+ private static readonly string[] _aliases = ["--quality", "-q"];
+
+ public QualityOption()
+ : base(_aliases, Properties.Resources.CLI_Option_Quality)
+ {
+ AddValidator(result =>
+ {
+ var value = result.GetValueOrDefault();
+ if (value.HasValue && (value.Value < 1 || value.Value > 100))
+ {
+ result.ErrorMessage = "JPEG quality must be between 1 and 100.";
+ }
+ });
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/RemoveMetadataOption.cs b/src/modules/imageresizer/ui/Cli/Options/RemoveMetadataOption.cs
new file mode 100644
index 0000000000..3db3ad2089
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/RemoveMetadataOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class RemoveMetadataOption : Option
+ {
+ private static readonly string[] _aliases = ["--remove-metadata"];
+
+ public RemoveMetadataOption()
+ : base(_aliases, Properties.Resources.CLI_Option_RemoveMetadata)
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/ReplaceOption.cs b/src/modules/imageresizer/ui/Cli/Options/ReplaceOption.cs
new file mode 100644
index 0000000000..c9a8073261
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/ReplaceOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class ReplaceOption : Option
+ {
+ private static readonly string[] _aliases = ["--replace", "-r"];
+
+ public ReplaceOption()
+ : base(_aliases, Properties.Resources.CLI_Option_Replace)
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/ShowConfigOption.cs b/src/modules/imageresizer/ui/Cli/Options/ShowConfigOption.cs
new file mode 100644
index 0000000000..c530662649
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/ShowConfigOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class ShowConfigOption : Option
+ {
+ private static readonly string[] _aliases = ["--show-config", "--config"];
+
+ public ShowConfigOption()
+ : base(_aliases, Properties.Resources.CLI_Option_ShowConfig)
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/ShrinkOnlyOption.cs b/src/modules/imageresizer/ui/Cli/Options/ShrinkOnlyOption.cs
new file mode 100644
index 0000000000..0f51adf642
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/ShrinkOnlyOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class ShrinkOnlyOption : Option
+ {
+ private static readonly string[] _aliases = ["--shrink-only"];
+
+ public ShrinkOnlyOption()
+ : base(_aliases, Properties.Resources.CLI_Option_ShrinkOnly)
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/SizeOption.cs b/src/modules/imageresizer/ui/Cli/Options/SizeOption.cs
new file mode 100644
index 0000000000..af3c978d2b
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/SizeOption.cs
@@ -0,0 +1,26 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class SizeOption : Option
+ {
+ private static readonly string[] _aliases = ["--size"];
+
+ public SizeOption()
+ : base(_aliases, Properties.Resources.CLI_Option_Size)
+ {
+ AddValidator(result =>
+ {
+ var value = result.GetValueOrDefault();
+ if (value.HasValue && value.Value < 0)
+ {
+ result.ErrorMessage = "Size index must be a non-negative integer.";
+ }
+ });
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/UnitOption.cs b/src/modules/imageresizer/ui/Cli/Options/UnitOption.cs
new file mode 100644
index 0000000000..dc9edde180
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/UnitOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class UnitOption : Option
+ {
+ private static readonly string[] _aliases = ["--unit", "-u"];
+
+ public UnitOption()
+ : base(_aliases, Properties.Resources.CLI_Option_Unit)
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Cli/Options/WidthOption.cs b/src/modules/imageresizer/ui/Cli/Options/WidthOption.cs
new file mode 100644
index 0000000000..64b8a2091d
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Options/WidthOption.cs
@@ -0,0 +1,18 @@
+// 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.CommandLine;
+
+namespace ImageResizer.Cli.Options
+{
+ public sealed class WidthOption : Option
+ {
+ private static readonly string[] _aliases = ["--width", "-w"];
+
+ public WidthOption()
+ : base(_aliases, Properties.Resources.CLI_Option_Width)
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/ImageResizerUI.csproj b/src/modules/imageresizer/ui/ImageResizerUI.csproj
index 3deb3ee5b7..2060ce6bf3 100644
--- a/src/modules/imageresizer/ui/ImageResizerUI.csproj
+++ b/src/modules/imageresizer/ui/ImageResizerUI.csproj
@@ -20,6 +20,7 @@
PowerToys.ImageResizer
{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}
true
+ CA1863
@@ -51,6 +52,7 @@
+
diff --git a/src/modules/imageresizer/ui/Models/CliOptions.cs b/src/modules/imageresizer/ui/Models/CliOptions.cs
new file mode 100644
index 0000000000..2df23f532b
--- /dev/null
+++ b/src/modules/imageresizer/ui/Models/CliOptions.cs
@@ -0,0 +1,261 @@
+// 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.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.CommandLine.Parsing;
+using System.Globalization;
+using ImageResizer.Cli.Commands;
+
+#pragma warning disable SA1649 // File name should match first type name
+#pragma warning disable SA1402 // File may only contain a single type
+
+namespace ImageResizer.Models
+{
+ ///
+ /// Represents the command-line options for ImageResizer CLI mode.
+ ///
+ public class CliOptions
+ {
+ ///
+ /// Gets or sets a value indicating whether to show help information.
+ ///
+ public bool ShowHelp { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to show current configuration.
+ ///
+ public bool ShowConfig { get; set; }
+
+ ///
+ /// Gets or sets the destination directory for resized images.
+ ///
+ public string DestinationDirectory { get; set; }
+
+ ///
+ /// Gets or sets the width of the resized image.
+ ///
+ public double? Width { get; set; }
+
+ ///
+ /// Gets or sets the height of the resized image.
+ ///
+ public double? Height { get; set; }
+
+ ///
+ /// Gets or sets the resize unit (Pixel, Percent, Inch, Centimeter).
+ ///
+ public ResizeUnit? Unit { get; set; }
+
+ ///
+ /// Gets or sets the resize fit mode (Fill, Fit, Stretch).
+ ///
+ public ResizeFit? Fit { get; set; }
+
+ ///
+ /// Gets or sets the index of the preset size to use.
+ ///
+ public int? SizeIndex { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to only shrink images (not enlarge).
+ ///
+ public bool? ShrinkOnly { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to replace the original file.
+ ///
+ public bool? Replace { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to ignore orientation when resizing.
+ ///
+ public bool? IgnoreOrientation { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to remove metadata from the resized image.
+ ///
+ public bool? RemoveMetadata { get; set; }
+
+ ///
+ /// Gets or sets the JPEG quality level (1-100).
+ ///
+ public int? JpegQualityLevel { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to keep the date modified.
+ ///
+ public bool? KeepDateModified { get; set; }
+
+ ///
+ /// Gets or sets the output filename format.
+ ///
+ public string FileName { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether to use line-based progress output for screen reader accessibility.
+ ///
+ public bool? ProgressLines { get; set; }
+
+ ///
+ /// Gets the list of files to process.
+ ///
+ public ICollection Files { get; } = new List();
+
+ ///
+ /// Gets or sets the pipe name for receiving file list.
+ ///
+ public string PipeName { get; set; }
+
+ ///
+ /// Gets parse/validation errors produced by System.CommandLine.
+ ///
+ public IReadOnlyList ParseErrors { get; private set; } = Array.Empty();
+
+ ///
+ /// Converts a boolean value to nullable bool (true -> true, false -> null).
+ ///
+ private static bool? ToBoolOrNull(bool value) => value ? true : null;
+
+ ///
+ /// Parses command-line arguments into CliOptions using System.CommandLine.
+ ///
+ /// The command-line arguments.
+ /// A CliOptions instance with parsed values.
+ public static CliOptions Parse(string[] args)
+ {
+ var options = new CliOptions();
+ var cmd = new ImageResizerRootCommand();
+
+ // Parse using System.CommandLine
+ var parseResult = new Parser(cmd).Parse(args);
+
+ if (parseResult.Errors.Count > 0)
+ {
+ var errors = new List(parseResult.Errors.Count);
+ foreach (var error in parseResult.Errors)
+ {
+ errors.Add(error.Message);
+ }
+
+ options.ParseErrors = new ReadOnlyCollection(errors);
+ }
+
+ // Extract values from parse result using strongly typed options
+ 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);
+
+ // Convert bool to nullable bool (true -> true, false -> null)
+ 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);
+
+ // Get files from arguments
+ var files = parseResult.GetValueForArgument(cmd.FilesArgument);
+ if (files != null)
+ {
+ const string pipeNamePrefix = "\\\\.\\pipe\\";
+ foreach (var file in files)
+ {
+ // Check for pipe name (must be at the start of the path)
+ if (file.StartsWith(pipeNamePrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ options.PipeName = file.Substring(pipeNamePrefix.Length);
+ }
+ else
+ {
+ options.Files.Add(file);
+ }
+ }
+ }
+
+ return options;
+ }
+
+ ///
+ /// Prints current configuration to the console.
+ ///
+ /// The settings to display.
+ public static void PrintConfig(ImageResizer.Properties.Settings settings)
+ {
+ Console.OutputEncoding = System.Text.Encoding.UTF8;
+ Console.WriteLine(Properties.Resources.CLI_ConfigTitle);
+ Console.WriteLine();
+ Console.WriteLine(Properties.Resources.CLI_ConfigGeneralSettings);
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigShrinkOnly, settings.ShrinkOnly));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigReplaceOriginal, settings.Replace));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigIgnoreOrientation, settings.IgnoreOrientation));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigRemoveMetadata, settings.RemoveMetadata));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigKeepDateModified, settings.KeepDateModified));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigJpegQuality, settings.JpegQualityLevel));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigPngInterlace, settings.PngInterlaceOption));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigTiffCompress, settings.TiffCompressOption));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigFilenameFormat, settings.FileName));
+ Console.WriteLine();
+ Console.WriteLine(Properties.Resources.CLI_ConfigCustomSize);
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigWidth, settings.CustomSize.Width, settings.CustomSize.Unit));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigHeight, settings.CustomSize.Height, settings.CustomSize.Unit));
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigFitMode, settings.CustomSize.Fit));
+ Console.WriteLine();
+ Console.WriteLine(Properties.Resources.CLI_ConfigPresetSizes);
+ for (int i = 0; i < settings.Sizes.Count; i++)
+ {
+ var size = settings.Sizes[i];
+ var selected = i == settings.SelectedSizeIndex ? "*" : " ";
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigPresetSizeFormat, i, selected, size.Name, size.Width, size.Height, size.Unit, size.Fit));
+ }
+
+ if (settings.SelectedSizeIndex >= settings.Sizes.Count)
+ {
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.CLI_ConfigCustomSelected, settings.CustomSize.Width, settings.CustomSize.Height, settings.CustomSize.Unit, settings.CustomSize.Fit));
+ }
+ }
+
+ ///
+ /// Prints usage information to the console.
+ ///
+ public static void PrintUsage()
+ {
+ Console.OutputEncoding = System.Text.Encoding.UTF8;
+ Console.WriteLine(Properties.Resources.CLI_UsageTitle);
+ Console.WriteLine();
+
+ var cmd = new ImageResizerRootCommand();
+
+ // Print usage line
+ Console.WriteLine(Properties.Resources.CLI_UsageLine);
+ Console.WriteLine();
+
+ // Print options from the command definition
+ Console.WriteLine(Properties.Resources.CLI_UsageOptions);
+ foreach (var option in cmd.Options)
+ {
+ var aliases = string.Join(", ", option.Aliases);
+ var description = option.Description ?? string.Empty;
+ Console.WriteLine($" {aliases,-30} {description}");
+ }
+
+ Console.WriteLine();
+ Console.WriteLine(Properties.Resources.CLI_UsageExamples);
+ Console.WriteLine(Properties.Resources.CLI_UsageExampleHelp);
+ Console.WriteLine(Properties.Resources.CLI_UsageExampleDimensions);
+ Console.WriteLine(Properties.Resources.CLI_UsageExamplePercent);
+ Console.WriteLine(Properties.Resources.CLI_UsageExamplePreset);
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Models/ResizeBatch.cs b/src/modules/imageresizer/ui/Models/ResizeBatch.cs
index b081094403..07df9cea75 100644
--- a/src/modules/imageresizer/ui/Models/ResizeBatch.cs
+++ b/src/modules/imageresizer/ui/Models/ResizeBatch.cs
@@ -10,6 +10,7 @@ using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.IO.Pipes;
+using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -39,44 +40,78 @@ namespace ImageResizer.Models
_aiSuperResolutionService = null;
}
- public static ResizeBatch FromCommandLine(TextReader standardInput, string[] args)
+ ///
+ /// Validates if a file path is a supported image format.
+ ///
+ /// The file path to validate.
+ /// True if the path is valid and points to a supported image file.
+ private static bool IsValidImagePath(string path)
{
- var batch = new ResizeBatch();
- const string pipeNamePrefix = "\\\\.\\pipe\\";
- string pipeName = null;
-
- for (var i = 0; i < args?.Length; i++)
+ if (string.IsNullOrWhiteSpace(path))
{
- if (args[i] == "/d")
- {
- batch.DestinationDirectory = args[++i];
- continue;
- }
- else if (args[i].Contains(pipeNamePrefix))
- {
- pipeName = args[i].Substring(pipeNamePrefix.Length);
- continue;
- }
-
- batch.Files.Add(args[i]);
+ return false;
}
- if (string.IsNullOrEmpty(pipeName))
+ if (!File.Exists(path))
+ {
+ return false;
+ }
+
+ var ext = Path.GetExtension(path)?.ToLowerInvariant();
+ var validExtensions = new[]
+ {
+ ".bmp", ".dib", ".gif", ".jfif", ".jpe", ".jpeg", ".jpg",
+ ".jxr", ".png", ".rle", ".tif", ".tiff", ".wdp",
+ };
+
+ return validExtensions.Contains(ext);
+ }
+
+ ///
+ /// Creates a ResizeBatch from CliOptions.
+ ///
+ /// Standard input stream for reading additional file paths.
+ /// The parsed CLI options.
+ /// A ResizeBatch instance.
+ public static ResizeBatch FromCliOptions(TextReader standardInput, CliOptions options)
+ {
+ var batch = new ResizeBatch
+ {
+ DestinationDirectory = options.DestinationDirectory,
+ };
+
+ foreach (var file in options.Files)
+ {
+ // Convert relative paths to absolute paths
+ var absolutePath = Path.IsPathRooted(file) ? file : Path.GetFullPath(file);
+ if (IsValidImagePath(absolutePath))
+ {
+ batch.Files.Add(absolutePath);
+ }
+ }
+
+ if (string.IsNullOrEmpty(options.PipeName))
{
// NB: We read these from stdin since there are limits on the number of args you can have
+ // Only read from stdin if it's redirected (piped input), not from interactive terminal
string file;
- if (standardInput != null)
+ if (standardInput != null && (Console.IsInputRedirected || !ReferenceEquals(standardInput, Console.In)))
{
while ((file = standardInput.ReadLine()) != null)
{
- batch.Files.Add(file);
+ // Convert relative paths to absolute paths
+ var absolutePath = Path.IsPathRooted(file) ? file : Path.GetFullPath(file);
+ if (IsValidImagePath(absolutePath))
+ {
+ batch.Files.Add(absolutePath);
+ }
}
}
}
else
{
using (NamedPipeClientStream pipeClient =
- new NamedPipeClientStream(".", pipeName, PipeDirection.In))
+ new NamedPipeClientStream(".", options.PipeName, PipeDirection.In))
{
// Connect to the pipe or wait until the pipe is available.
pipeClient.Connect();
@@ -88,7 +123,10 @@ namespace ImageResizer.Models
// Display the read text to the console
while ((file = sr.ReadLine()) != null)
{
- batch.Files.Add(file);
+ if (IsValidImagePath(file))
+ {
+ batch.Files.Add(file);
+ }
}
}
}
@@ -97,17 +135,26 @@ namespace ImageResizer.Models
return batch;
}
+ public static ResizeBatch FromCommandLine(TextReader standardInput, string[] args)
+ {
+ var options = CliOptions.Parse(args);
+ return FromCliOptions(standardInput, options);
+ }
+
public IEnumerable Process(Action reportProgress, CancellationToken cancellationToken)
+ {
+ // NOTE: Settings.Default is captured once before parallel processing.
+ // Any changes to settings on disk during this batch will NOT be reflected until the next batch.
+ // This improves performance and predictability by avoiding repeated mutex acquisition and behaviour change results in a batch.
+ return Process(reportProgress, Settings.Default, cancellationToken);
+ }
+
+ public IEnumerable Process(Action reportProgress, Settings settings, CancellationToken cancellationToken)
{
double total = Files.Count;
int completed = 0;
var errors = new ConcurrentBag();
- // NOTE: Settings.Default is captured once before parallel processing.
- // Any changes to settings on disk during this batch will NOT be reflected until the next batch.
- // This improves performance and predictability by avoiding repeated mutex acquisition and behaviour change results in a batch.
- var settings = Settings.Default;
-
// TODO: If we ever switch to Windows.Graphics.Imaging, we can get a lot more throughput by using the async
// APIs and a custom SynchronizationContext
Parallel.ForEach(
diff --git a/src/modules/imageresizer/ui/Properties/Resources.Designer.cs b/src/modules/imageresizer/ui/Properties/Resources.Designer.cs
index 22166e73b9..916f38e890 100644
--- a/src/modules/imageresizer/ui/Properties/Resources.Designer.cs
+++ b/src/modules/imageresizer/ui/Properties/Resources.Designer.cs
@@ -716,5 +716,437 @@ namespace ImageResizer.Properties {
return ResourceManager.GetString("Width", resourceCulture);
}
}
+
+ ///
+ /// Looks up a localized string similar to Processing {0} files....
+ ///
+ public static string CLI_ProcessingFiles {
+ get {
+ return ResourceManager.GetString("CLI_ProcessingFiles", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to [{0}%] {1}/{2} completed.
+ ///
+ public static string CLI_ProgressFormat {
+ get {
+ return ResourceManager.GetString("CLI_ProgressFormat", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Completed with {0} error(s)..
+ ///
+ public static string CLI_CompletedWithErrors {
+ get {
+ return ResourceManager.GetString("CLI_CompletedWithErrors", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to All files processed successfully!.
+ ///
+ public static string CLI_AllFilesProcessed {
+ get {
+ return ResourceManager.GetString("CLI_AllFilesProcessed", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to No input files or pipe specified. Showing usage..
+ ///
+ public static string CLI_NoInputFiles {
+ get {
+ return ResourceManager.GetString("CLI_NoInputFiles", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Warning: Size index {0} is invalid. Using custom size..
+ ///
+ public static string CLI_WarningInvalidSizeIndex {
+ get {
+ return ResourceManager.GetString("CLI_WarningInvalidSizeIndex", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Current Configuration:.
+ ///
+ public static string CLI_ConfigTitle {
+ get {
+ return ResourceManager.GetString("CLI_ConfigTitle", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to General Settings:.
+ ///
+ public static string CLI_ConfigGeneralSettings {
+ get {
+ return ResourceManager.GetString("CLI_ConfigGeneralSettings", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Shrink only: {0}.
+ ///
+ public static string CLI_ConfigShrinkOnly {
+ get {
+ return ResourceManager.GetString("CLI_ConfigShrinkOnly", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Replace original: {0}.
+ ///
+ public static string CLI_ConfigReplaceOriginal {
+ get {
+ return ResourceManager.GetString("CLI_ConfigReplaceOriginal", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Ignore orientation: {0}.
+ ///
+ public static string CLI_ConfigIgnoreOrientation {
+ get {
+ return ResourceManager.GetString("CLI_ConfigIgnoreOrientation", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Remove metadata: {0}.
+ ///
+ public static string CLI_ConfigRemoveMetadata {
+ get {
+ return ResourceManager.GetString("CLI_ConfigRemoveMetadata", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Keep date modified: {0}.
+ ///
+ public static string CLI_ConfigKeepDateModified {
+ get {
+ return ResourceManager.GetString("CLI_ConfigKeepDateModified", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to JPEG quality: {0}.
+ ///
+ public static string CLI_ConfigJpegQuality {
+ get {
+ return ResourceManager.GetString("CLI_ConfigJpegQuality", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to PNG interlace: {0}.
+ ///
+ public static string CLI_ConfigPngInterlace {
+ get {
+ return ResourceManager.GetString("CLI_ConfigPngInterlace", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to TIFF compress: {0}.
+ ///
+ public static string CLI_ConfigTiffCompress {
+ get {
+ return ResourceManager.GetString("CLI_ConfigTiffCompress", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Filename format: {0}.
+ ///
+ public static string CLI_ConfigFilenameFormat {
+ get {
+ return ResourceManager.GetString("CLI_ConfigFilenameFormat", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Custom Size:.
+ ///
+ public static string CLI_ConfigCustomSize {
+ get {
+ return ResourceManager.GetString("CLI_ConfigCustomSize", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Width: {0}.
+ ///
+ public static string CLI_ConfigWidth {
+ get {
+ return ResourceManager.GetString("CLI_ConfigWidth", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Height: {0}.
+ ///
+ public static string CLI_ConfigHeight {
+ get {
+ return ResourceManager.GetString("CLI_ConfigHeight", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Fit mode: {0}.
+ ///
+ public static string CLI_ConfigFitMode {
+ get {
+ return ResourceManager.GetString("CLI_ConfigFitMode", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Preset Sizes: (* = currently selected).
+ ///
+ public static string CLI_ConfigPresetSizes {
+ get {
+ return ResourceManager.GetString("CLI_ConfigPresetSizes", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to {0}: {1} x {2} ({3}).
+ ///
+ public static string CLI_ConfigPresetSizeFormat {
+ get {
+ return ResourceManager.GetString("CLI_ConfigPresetSizeFormat", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to → Custom size selected.
+ ///
+ public static string CLI_ConfigCustomSelected {
+ get {
+ return ResourceManager.GetString("CLI_ConfigCustomSelected", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Image Resizer CLI.
+ ///
+ public static string CLI_UsageTitle {
+ get {
+ return ResourceManager.GetString("CLI_UsageTitle", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Usage: PowerToys.ImageResizer.exe [options] <files>.
+ ///
+ public static string CLI_UsageLine {
+ get {
+ return ResourceManager.GetString("CLI_UsageLine", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Options:.
+ ///
+ public static string CLI_UsageOptions {
+ get {
+ return ResourceManager.GetString("CLI_UsageOptions", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Examples:.
+ ///
+ public static string CLI_UsageExamples {
+ get {
+ return ResourceManager.GetString("CLI_UsageExamples", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to PowerToys.ImageResizer.exe --help.
+ ///
+ public static string CLI_UsageExampleHelp {
+ get {
+ return ResourceManager.GetString("CLI_UsageExampleHelp", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to PowerToys.ImageResizer.exe --width 800 --height 600 image.jpg.
+ ///
+ public static string CLI_UsageExampleDimensions {
+ get {
+ return ResourceManager.GetString("CLI_UsageExampleDimensions", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to PowerToys.ImageResizer.exe --size 50 --unit percent *.jpg.
+ ///
+ public static string CLI_UsageExamplePercent {
+ get {
+ return ResourceManager.GetString("CLI_UsageExamplePercent", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to PowerToys.ImageResizer.exe --size 2 image1.png image2.png.
+ ///
+ public static string CLI_UsageExamplePreset {
+ get {
+ return ResourceManager.GetString("CLI_UsageExamplePreset", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Destination directory for resized images.
+ ///
+ public static string CLI_Option_Destination {
+ get {
+ return ResourceManager.GetString("CLI_Option_Destination", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Output filename format (e.g., %1 (%2)).
+ ///
+ public static string CLI_Option_FileName {
+ get {
+ return ResourceManager.GetString("CLI_Option_FileName", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Image files to resize.
+ ///
+ public static string CLI_Option_Files {
+ get {
+ return ResourceManager.GetString("CLI_Option_Files", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to How to fit image: fill, fit, stretch.
+ ///
+ public static string CLI_Option_Fit {
+ get {
+ return ResourceManager.GetString("CLI_Option_Fit", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Height of the resized image in pixels.
+ ///
+ public static string CLI_Option_Height {
+ get {
+ return ResourceManager.GetString("CLI_Option_Height", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Display this help message.
+ ///
+ public static string CLI_Option_Help {
+ get {
+ return ResourceManager.GetString("CLI_Option_Help", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Ignore image orientation metadata.
+ ///
+ public static string CLI_Option_IgnoreOrientation {
+ get {
+ return ResourceManager.GetString("CLI_Option_IgnoreOrientation", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Preserve the original file modification date.
+ ///
+ public static string CLI_Option_KeepDateModified {
+ get {
+ return ResourceManager.GetString("CLI_Option_KeepDateModified", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Set JPEG quality level (1-100).
+ ///
+ public static string CLI_Option_Quality {
+ get {
+ return ResourceManager.GetString("CLI_Option_Quality", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Remove image metadata during resizing.
+ ///
+ public static string CLI_Option_RemoveMetadata {
+ get {
+ return ResourceManager.GetString("CLI_Option_RemoveMetadata", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Replace the original image file.
+ ///
+ public static string CLI_Option_Replace {
+ get {
+ return ResourceManager.GetString("CLI_Option_Replace", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Display current configuration.
+ ///
+ public static string CLI_Option_ShowConfig {
+ get {
+ return ResourceManager.GetString("CLI_Option_ShowConfig", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Only shrink images, do not enlarge.
+ ///
+ public static string CLI_Option_ShrinkOnly {
+ get {
+ return ResourceManager.GetString("CLI_Option_ShrinkOnly", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Use preset size by index (0-based).
+ ///
+ public static string CLI_Option_Size {
+ get {
+ return ResourceManager.GetString("CLI_Option_Size", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Unit of measurement: pixel, percent, cm, inch.
+ ///
+ public static string CLI_Option_Unit {
+ get {
+ return ResourceManager.GetString("CLI_Option_Unit", resourceCulture);
+ }
+ }
+
+ ///
+ /// Looks up a localized string similar to Width of the resized image in pixels.
+ ///
+ public static string CLI_Option_Width {
+ get {
+ return ResourceManager.GetString("CLI_Option_Width", resourceCulture);
+ }
+ }
}
}
diff --git a/src/modules/imageresizer/ui/Properties/Resources.resx b/src/modules/imageresizer/ui/Properties/Resources.resx
index b549e2b06d..a939ab1808 100644
--- a/src/modules/imageresizer/ui/Properties/Resources.resx
+++ b/src/modules/imageresizer/ui/Properties/Resources.resx
@@ -347,4 +347,156 @@
Upscale images using on-device AI
+
+
+
+ Processing {0} file(s)...
+
+
+ Progress: {0}% ({1}/{2})
+
+
+ Completed with {0} error(s):
+
+
+ All files processed successfully.
+
+
+ Warning: Invalid size index {0}. Using default.
+
+
+ No input files or pipe specified. Showing usage.
+
+
+
+
+ ImageResizer - Current Configuration
+
+
+ General Settings:
+
+
+ Shrink Only: {0}
+
+
+ Replace Original: {0}
+
+
+ Ignore Orientation: {0}
+
+
+ Remove Metadata: {0}
+
+
+ Keep Date Modified: {0}
+
+
+ JPEG Quality: {0}
+
+
+ PNG Interlace: {0}
+
+
+ TIFF Compress: {0}
+
+
+ Filename Format: {0}
+
+
+ Custom Size:
+
+
+ Width: {0} {1}
+
+
+ Height: {0} {1}
+
+
+ Fit Mode: {0}
+
+
+ Preset Sizes: (* = currently selected)
+
+
+ [{0}]{1} {2}: {3}x{4} {5} ({6})
+
+
+ [Custom]* {0}x{1} {2} ({3})
+
+
+
+
+ ImageResizer - PowerToys Image Resizer CLI
+
+
+ Usage: PowerToys.ImageResizerCLI.exe [options] [files...]
+
+
+ Options:
+
+
+ Examples:
+
+
+ PowerToys.ImageResizerCLI.exe --help
+
+
+ PowerToys.ImageResizerCLI.exe --width 800 --height 600 image.jpg
+
+
+ PowerToys.ImageResizerCLI.exe -w 50 -h 50 -u Percent *.jpg
+
+
+ PowerToys.ImageResizerCLI.exe --size 0 -d "C:\Output" photo.png
+
+
+
+
+ Set destination directory
+
+
+ Set output filename format (%1=original name, %2=size name)
+
+
+ Image files to resize
+
+
+ Set fit mode (Fill, Fit, Stretch)
+
+
+ Set height
+
+
+ Show help information
+
+
+ Ignore image orientation
+
+
+ Keep original date modified
+
+
+ Set JPEG quality level (1-100)
+
+
+ Replace original files
+
+
+ Show current configuration
+
+
+ Only shrink images, don't enlarge
+
+
+ Remove metadata from resized images
+
+
+ Use preset size by index (0-based)
+
+
+ Set unit (Pixel, Percent, Inch, Centimeter)
+
+
+ Set width
+
\ No newline at end of file
diff --git a/src/modules/imageresizer/ui/Properties/Settings.cs b/src/modules/imageresizer/ui/Properties/Settings.cs
index 63a6680d2e..542f82e0a8 100644
--- a/src/modules/imageresizer/ui/Properties/Settings.cs
+++ b/src/modules/imageresizer/ui/Properties/Settings.cs
@@ -15,6 +15,7 @@ using System.IO.Abstractions;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Windows.Media.Imaging;
@@ -42,6 +43,7 @@ namespace ImageResizer.Properties
{
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
WriteIndented = true,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
};
private static readonly CompositeFormat ValueMustBeBetween = System.Text.CompositeFormat.Parse(Properties.Resources.ValueMustBeBetween);