diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json
index e3ebffc20c..050fe426dc 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 1884b2d58b..2aa1ec2b9f 100644
--- a/PowerToys.slnx
+++ b/PowerToys.slnx
@@ -442,6 +442,10 @@
+
+
+
+
diff --git a/doc/devdocs/cli-conventions.md b/doc/devdocs/cli-conventions.md
new file mode 100644
index 0000000000..969f2a32bf
--- /dev/null
+++ b/doc/devdocs/cli-conventions.md
@@ -0,0 +1,182 @@
+# 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
+
+### Aliases Pattern
+
+Define option aliases as static readonly arrays following this pattern:
+
+```csharp
+private static readonly string[] AliasesSilent = ["--silent", "-s"];
+private static readonly string[] AliasesWidth = ["--width", "-w"];
+private static readonly string[] AliasesHelp = ["--help"];
+```
+
+When creating dedicated option types (for example, `sealed class FooOption : Option`),
+avoid naming a member `Aliases` (it hides `Option.Aliases`). Prefer `_aliases`.
+
+### Naming Rules
+
+1. **Long form**: Use `--kebab-case` (e.g., `--shrink-only`, `--keep-date-modified`)
+2. **Short form**: Use single `-x` character (e.g., `-s`, `-w`, `-h`)
+3. **No short form** for less common options (e.g., `--shrink-only`, `--ignore-orientation`)
+
+## Option Definition
+
+Create options using `Option` with descriptive help text:
+
+```csharp
+var silentOption = new Option(AliasesSilent, "Run in silent mode without UI");
+var widthOption = new Option(AliasesWidth, "Set width in pixels");
+var unitOption = new Option(AliasesUnit, "Set unit (Pixel, Percent, Inch, Centimeter)");
+```
+
+## Validation
+
+Add validators for options that require range or format checking:
+
+```csharp
+qualityOption.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.";
+ }
+});
+```
+
+## RootCommand Setup
+
+Create a `RootCommand` with a description and add all options:
+
+```csharp
+public static RootCommand CreateRootCommand()
+{
+ var rootCommand = new RootCommand("PowerToys Module Name - Brief description")
+ {
+ silentOption,
+ widthOption,
+ heightOption,
+ // ... other options
+ filesArgument,
+ };
+
+ return rootCommand;
+}
+```
+
+## Parsing
+
+Parse arguments and extract values:
+
+```csharp
+public static CliOptions Parse(string[] args)
+{
+ var options = new CliOptions();
+ var rootCommand = CreateRootCommand();
+ // Note: with the pinned System.CommandLine version in this repo,
+ // RootCommand.Parse(args) may not be available. Use Parser instead.
+ var parseResult = new Parser(rootCommand).Parse(args);
+
+ // Extract values
+ options.Silent = parseResult.GetValueForOption(silentOption);
+ options.Width = parseResult.GetValueForOption(widthOption);
+
+ return options;
+}
+```
+
+### Parse/Validation Errors
+
+If parsing or validation fails, return a non-zero exit code (and typically print
+the errors plus usage):
+
+```csharp
+if (parseResult.Errors.Count > 0)
+{
+ foreach (var error in parseResult.Errors)
+ {
+ Console.Error.WriteLine(error.Message);
+ }
+
+ PrintUsage();
+ return 1;
+}
+```
+
+## Examples
+
+### Awake Module
+
+Reference implementation: `src/modules/Awake/Awake/Program.cs`
+
+```csharp
+private static readonly string[] _aliasesConfigOption = ["--use-pt-config", "-c"];
+private static readonly string[] _aliasesDisplayOption = ["--display-on", "-d"];
+private static readonly string[] _aliasesTimeOption = ["--time-limit", "-t"];
+private static readonly string[] _aliasesPidOption = ["--pid", "-p"];
+private static readonly string[] _aliasesExpireAtOption = ["--expire-at", "-e"];
+```
+
+### ImageResizer Module
+
+Reference implementation:
+
+- `src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs`
+- `src/modules/imageresizer/ui/Models/CliOptions.cs`
+- `src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs`
+
+```csharp
+public sealed class DestinationOption : Option
+{
+ private static readonly string[] _aliases = ["--destination", "-d"];
+
+ public DestinationOption()
+ : base(_aliases, "Set destination directory")
+ {
+ }
+}
+```
+
+## Help Output
+
+Provide a `PrintUsage()` method for custom help formatting if needed:
+
+```csharp
+public static void PrintUsage()
+{
+ Console.WriteLine("ModuleName - PowerToys Module CLI");
+ Console.WriteLine();
+ Console.WriteLine("Usage: PowerToys.ModuleName.exe [options] [arguments...]");
+ Console.WriteLine();
+ Console.WriteLine("Options:");
+ Console.WriteLine(" --option, -o Description of the option");
+ // ...
+}
+```
+
+## Best Practices
+
+1. **Consistency**: Follow existing patterns in the codebase (Awake, ImageResizer)
+2. **Documentation**: Always provide help text for each option
+3. **Validation**: Validate input values and provide clear error messages
+4. **Nullable types**: Use `Option` for optional parameters
+5. **Boolean flags**: Use `Option` for flags that don't require values
+6. **Enum support**: System.CommandLine automatically handles enum parsing
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..0754f68bef
--- /dev/null
+++ b/src/modules/imageresizer/ImageResizerCLI/Program.cs
@@ -0,0 +1,34 @@
+// 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;
+ return ImageResizerCliExecutor.RunStandalone(args);
+ }
+}
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..5a463394df
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs
@@ -0,0 +1,86 @@
+// 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();
+ 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);
+ 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 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..999c831a27
--- /dev/null
+++ b/src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs
@@ -0,0 +1,188 @@
+// 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 System.Threading;
+
+using ImageResizer.Models;
+using ImageResizer.Properties;
+
+namespace ImageResizer.Cli
+{
+ ///
+ /// Centralizes the Image Resizer CLI execution logic for the dedicated CLI host.
+ ///
+ public static class ImageResizerCliExecutor
+ {
+ ///
+ /// Entry point used by the dedicated CLI host.
+ ///
+ /// Command-line arguments.
+ /// Exit code.
+ public static int RunStandalone(string[] args)
+ {
+ var cliOptions = CliOptions.Parse(args);
+
+ if (cliOptions.ParseErrors.Count > 0)
+ {
+ foreach (var error in cliOptions.ParseErrors)
+ {
+ Console.Error.WriteLine(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))
+ {
+ CliOptions.PrintUsage();
+ return 1;
+ }
+
+ return RunSilentMode(cliOptions);
+ }
+
+ private static int RunSilentMode(CliOptions cliOptions)
+ {
+ var batch = ResizeBatch.FromCliOptions(Console.In, cliOptions);
+ var settings = Settings.Default;
+ ApplyCliOptionsToSettings(cliOptions, settings);
+
+ Console.WriteLine($"Processing {batch.Files.Count} file(s)...");
+
+ var errors = batch.Process(
+ (completed, total) =>
+ {
+ var progress = (int)((completed / total) * 100);
+ Console.Write($"\rProgress: {progress}% ({completed}/{(int)total})");
+ },
+ settings,
+ CancellationToken.None);
+
+ Console.WriteLine();
+
+ var errorList = errors.ToList();
+ if (errorList.Count > 0)
+ {
+ Console.Error.WriteLine($"Completed with {errorList.Count} error(s):");
+ foreach (var error in errorList)
+ {
+ Console.Error.WriteLine($" {error.File}: {error.Error}");
+ }
+
+ return 1;
+ }
+
+ Console.WriteLine("All files processed successfully.");
+ return 0;
+ }
+
+ ///
+ /// Applies CLI options to the settings, overriding default values.
+ ///
+ /// The CLI options to apply.
+ /// The settings to modify.
+ private static void ApplyCliOptionsToSettings(CliOptions cliOptions, Settings settings)
+ {
+ // If custom width/height specified, use custom size
+ if (cliOptions.Width.HasValue || cliOptions.Height.HasValue)
+ {
+ if (cliOptions.Width.HasValue)
+ {
+ settings.CustomSize.Width = cliOptions.Width.Value;
+ }
+ else
+ {
+ // If only height specified, set width to 0 for auto-calculation in Fit mode
+ settings.CustomSize.Width = 0;
+ }
+
+ if (cliOptions.Height.HasValue)
+ {
+ settings.CustomSize.Height = cliOptions.Height.Value;
+ }
+ else
+ {
+ // If only width specified, set height to 0 for auto-calculation in Fit mode
+ settings.CustomSize.Height = 0;
+ }
+
+ 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;
+ }
+ else if (cliOptions.SizeIndex.HasValue)
+ {
+ // Use preset size by index
+ if (cliOptions.SizeIndex.Value >= 0 && cliOptions.SizeIndex.Value < settings.Sizes.Count)
+ {
+ settings.SelectedSizeIndex = cliOptions.SizeIndex.Value;
+ }
+ else
+ {
+ Console.Error.WriteLine($"Warning: Invalid size index {cliOptions.SizeIndex.Value}. Using default.");
+ }
+ }
+
+ // Apply other options
+ 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/Options/DestinationOption.cs b/src/modules/imageresizer/ui/Cli/Options/DestinationOption.cs
new file mode 100644
index 0000000000..85b122b894
--- /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"];
+
+ public DestinationOption()
+ : base(_aliases, "Set destination directory")
+ {
+ }
+ }
+}
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..ee3e7db8f3
--- /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, "Set output filename format (%1=original name, %2=size name)")
+ {
+ }
+ }
+}
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..f8b4ca5c66
--- /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", "Image files to resize")
+ {
+ 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..c0fe11fd47
--- /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, "Set fit mode (Fill, Fit, Stretch)")
+ {
+ }
+ }
+}
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..0e95129e25
--- /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, "Set 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..fcb472c652
--- /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, "Show help information")
+ {
+ }
+ }
+}
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..d470714b73
--- /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, "Ignore image orientation")
+ {
+ }
+ }
+}
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..9967d347ef
--- /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, "Keep original date modified")
+ {
+ }
+ }
+}
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..babc597038
--- /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, "Set JPEG quality level (1-100)")
+ {
+ 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..22c8ad918b
--- /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, "Remove metadata from resized images")
+ {
+ }
+ }
+}
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..9b5ed6333b
--- /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, "Replace original files")
+ {
+ }
+ }
+}
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..ac82519d34
--- /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, "Show current configuration")
+ {
+ }
+ }
+}
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..d61b5ec10b
--- /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, "Only shrink images, don't enlarge")
+ {
+ }
+ }
+}
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..7d1ed83bf3
--- /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, "Use preset size by index (0-based)")
+ {
+ 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..9158c618f8
--- /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, "Set unit (Pixel, Percent, Inch, Centimeter)")
+ {
+ }
+ }
+}
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..072f25c079
--- /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, "Set width")
+ {
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/ImageResizerUI.csproj b/src/modules/imageresizer/ui/ImageResizerUI.csproj
index 3ce98d8386..d927e9f2cb 100644
--- a/src/modules/imageresizer/ui/ImageResizerUI.csproj
+++ b/src/modules/imageresizer/ui/ImageResizerUI.csproj
@@ -47,6 +47,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..b066bf7ede
--- /dev/null
+++ b/src/modules/imageresizer/ui/Models/CliOptions.cs
@@ -0,0 +1,256 @@
+// 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 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 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.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("ImageResizer - Current Configuration");
+ Console.WriteLine();
+ Console.WriteLine("General Settings:");
+ Console.WriteLine($" Shrink Only: {settings.ShrinkOnly}");
+ Console.WriteLine($" Replace Original: {settings.Replace}");
+ Console.WriteLine($" Ignore Orientation: {settings.IgnoreOrientation}");
+ Console.WriteLine($" Remove Metadata: {settings.RemoveMetadata}");
+ Console.WriteLine($" Keep Date Modified: {settings.KeepDateModified}");
+ Console.WriteLine($" JPEG Quality: {settings.JpegQualityLevel}");
+ Console.WriteLine($" PNG Interlace: {settings.PngInterlaceOption}");
+ Console.WriteLine($" TIFF Compress: {settings.TiffCompressOption}");
+ Console.WriteLine($" Filename Format: {settings.FileName}");
+ Console.WriteLine();
+ Console.WriteLine("Custom Size:");
+ Console.WriteLine($" Width: {settings.CustomSize.Width} {settings.CustomSize.Unit}");
+ Console.WriteLine($" Height: {settings.CustomSize.Height} {settings.CustomSize.Unit}");
+ Console.WriteLine($" Fit Mode: {settings.CustomSize.Fit}");
+ Console.WriteLine();
+ Console.WriteLine("Preset Sizes:");
+ for (int i = 0; i < settings.Sizes.Count; i++)
+ {
+ var size = settings.Sizes[i];
+ var selected = i == settings.SelectedSizeIndex ? "*" : " ";
+ Console.WriteLine($" [{i}]{selected} {size.Name}: {size.Width}x{size.Height} {size.Unit} ({size.Fit})");
+ }
+
+ if (settings.SelectedSizeIndex >= settings.Sizes.Count)
+ {
+ Console.WriteLine($" [Custom]* {settings.CustomSize.Width}x{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("ImageResizer - PowerToys Image Resizer CLI");
+ Console.WriteLine();
+
+ var cmd = new ImageResizerRootCommand();
+
+ // Print usage line
+ Console.WriteLine("Usage: PowerToys.ImageResizerCLI.exe [options] [files...]");
+ Console.WriteLine();
+
+ // Print options from the command definition
+ Console.WriteLine("Options:");
+ 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("Examples:");
+ Console.WriteLine(" PowerToys.ImageResizerCLI.exe --help");
+ Console.WriteLine(" PowerToys.ImageResizerCLI.exe --width 800 --height 600 image.jpg");
+ Console.WriteLine(" PowerToys.ImageResizerCLI.exe -w 50 -h 50 -u Percent *.jpg");
+ Console.WriteLine(" PowerToys.ImageResizerCLI.exe --size 0 -d \"C:\\Output\" photo.png");
+ }
+ }
+}
diff --git a/src/modules/imageresizer/ui/Models/ResizeBatch.cs b/src/modules/imageresizer/ui/Models/ResizeBatch.cs
index 87e0b84e7b..87fcd3a242 100644
--- a/src/modules/imageresizer/ui/Models/ResizeBatch.cs
+++ b/src/modules/imageresizer/ui/Models/ResizeBatch.cs
@@ -26,44 +26,45 @@ namespace ImageResizer.Models
public ICollection Files { get; } = new List();
- public static ResizeBatch FromCommandLine(TextReader standardInput, string[] args)
+ ///
+ /// 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();
- const string pipeNamePrefix = "\\\\.\\pipe\\";
- string pipeName = null;
-
- for (var i = 0; i < args?.Length; i++)
+ var batch = new ResizeBatch
{
- if (args[i] == "/d")
- {
- batch.DestinationDirectory = args[++i];
- continue;
- }
- else if (args[i].Contains(pipeNamePrefix))
- {
- pipeName = args[i].Substring(pipeNamePrefix.Length);
- continue;
- }
+ DestinationDirectory = options.DestinationDirectory,
+ };
- batch.Files.Add(args[i]);
+ foreach (var file in options.Files)
+ {
+ // Convert relative paths to absolute paths
+ var absolutePath = Path.IsPathRooted(file) ? file : Path.GetFullPath(file);
+ batch.Files.Add(absolutePath);
}
- if (string.IsNullOrEmpty(pipeName))
+ 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)
{
while ((file = standardInput.ReadLine()) != null)
{
- batch.Files.Add(file);
+ // Convert relative paths to absolute paths
+ var absolutePath = Path.IsPathRooted(file) ? file : Path.GetFullPath(file);
+ 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();
@@ -84,17 +85,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/Settings.cs b/src/modules/imageresizer/ui/Properties/Settings.cs
index 0f8690dcbb..6b54a3fad1 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;
@@ -30,6 +31,7 @@ namespace ImageResizer.Properties
{
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
WriteIndented = true,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
};
private static readonly CompositeFormat ValueMustBeBetween = System.Text.CompositeFormat.Parse(Properties.Resources.ValueMustBeBetween);