mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 09:46:54 +02:00
Add standard CLI support for Image Resizer (#44287)
<!-- Enter a brief description/summary of your PR here. What does it fix/what does it change/how was it tested (even manually, if necessary)? --> ## Summary of the Pull Request Adds a dedicated command-line interface (CLI) executable for Image Resizer (PowerToys.ImageResizerCLI.exe) ## Command `PowerToys.ImageResizerCLI.exe [options] [files...]` ## Options (High Level) | Option (aliases) | Description | |-----------------|-------------| | `--help` | Show help | | `--show-config` | Print current effective configuration | | `--destination`, `-d` | Output directory (optional) | | `--width`, `-w` | Width | | `--height`, `-h` | Height | | `--unit`, `-u` | Unit (Pixel / Percent / Inch / Centimeter) | | `--fit`, `-f` | Fit mode (Fill / Fit / Stretch) | | `--size`, `-s` | Preset size index (supports `0` for Custom) | | `--shrink-only` | Only shrink (do not enlarge) | | `--replace` | Replace original | | `--ignore-orientation` | Ignore EXIF orientation | | `--remove-metadata` | Strip metadata | | `--quality`, `-q` | JPEG quality (1–100) | | `--keep-date-modified` | Preserve source last-write time | | `--file-name` | Output filename format | ## Example usage ``` # Show help PowerToys.ImageResizerCLI.exe --help # Show current config PowerToys.ImageResizerCLI.exe --show-config # Resize with explicit dimensions PowerToys.ImageResizerCLI.exe --width 800 --height 600 .\image.png # Use preset size 0 (Custom) and output to a folder PowerToys.ImageResizerCLI.exe --size 0 -d "C:\Output" .\photo.png # Preserve source LastWriteTime PowerToys.ImageResizerCLI.exe --width 800 --height 600 --keep-date-modified -d "C:\Output" .\image.png ```  <!-- Please review the items on the PR checklist before submitting--> ## PR Checklist - [ ] Closes: #xxx <!-- - [ ] Closes: #yyy (add separate lines for additional resolved issues) --> - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [x] **Tests:** Added/updated and all pass - [x] **Localization:** All end-user-facing strings can be localized - [x] **Dev docs:** Added/updated - [x] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx <!-- Provide a more detailed description of the PR, other things fixed, or any additional comments/features here --> ## Detailed Description of the Pull Request / Additional comments <!-- Describe how you validated the behavior. Add automated tests wherever possible, but list manual validation steps taken as well --> ## Validation Steps Performed
This commit is contained in:
261
src/modules/imageresizer/ui/Models/CliOptions.cs
Normal file
261
src/modules/imageresizer/ui/Models/CliOptions.cs
Normal file
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents the command-line options for ImageResizer CLI mode.
|
||||
/// </summary>
|
||||
public class CliOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to show help information.
|
||||
/// </summary>
|
||||
public bool ShowHelp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to show current configuration.
|
||||
/// </summary>
|
||||
public bool ShowConfig { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the destination directory for resized images.
|
||||
/// </summary>
|
||||
public string DestinationDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the width of the resized image.
|
||||
/// </summary>
|
||||
public double? Width { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the height of the resized image.
|
||||
/// </summary>
|
||||
public double? Height { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the resize unit (Pixel, Percent, Inch, Centimeter).
|
||||
/// </summary>
|
||||
public ResizeUnit? Unit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the resize fit mode (Fill, Fit, Stretch).
|
||||
/// </summary>
|
||||
public ResizeFit? Fit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the index of the preset size to use.
|
||||
/// </summary>
|
||||
public int? SizeIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to only shrink images (not enlarge).
|
||||
/// </summary>
|
||||
public bool? ShrinkOnly { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to replace the original file.
|
||||
/// </summary>
|
||||
public bool? Replace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to ignore orientation when resizing.
|
||||
/// </summary>
|
||||
public bool? IgnoreOrientation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to remove metadata from the resized image.
|
||||
/// </summary>
|
||||
public bool? RemoveMetadata { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the JPEG quality level (1-100).
|
||||
/// </summary>
|
||||
public int? JpegQualityLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to keep the date modified.
|
||||
/// </summary>
|
||||
public bool? KeepDateModified { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the output filename format.
|
||||
/// </summary>
|
||||
public string FileName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to use line-based progress output for screen reader accessibility.
|
||||
/// </summary>
|
||||
public bool? ProgressLines { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of files to process.
|
||||
/// </summary>
|
||||
public ICollection<string> Files { get; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the pipe name for receiving file list.
|
||||
/// </summary>
|
||||
public string PipeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets parse/validation errors produced by System.CommandLine.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> ParseErrors { get; private set; } = Array.Empty<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Converts a boolean value to nullable bool (true -> true, false -> null).
|
||||
/// </summary>
|
||||
private static bool? ToBoolOrNull(bool value) => value ? true : null;
|
||||
|
||||
/// <summary>
|
||||
/// Parses command-line arguments into CliOptions using System.CommandLine.
|
||||
/// </summary>
|
||||
/// <param name="args">The command-line arguments.</param>
|
||||
/// <returns>A CliOptions instance with parsed values.</returns>
|
||||
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<string>(parseResult.Errors.Count);
|
||||
foreach (var error in parseResult.Errors)
|
||||
{
|
||||
errors.Add(error.Message);
|
||||
}
|
||||
|
||||
options.ParseErrors = new ReadOnlyCollection<string>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prints current configuration to the console.
|
||||
/// </summary>
|
||||
/// <param name="settings">The settings to display.</param>
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prints usage information to the console.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
/// <summary>
|
||||
/// Validates if a file path is a supported image format.
|
||||
/// </summary>
|
||||
/// <param name="path">The file path to validate.</param>
|
||||
/// <returns>True if the path is valid and points to a supported image file.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a ResizeBatch from CliOptions.
|
||||
/// </summary>
|
||||
/// <param name="standardInput">Standard input stream for reading additional file paths.</param>
|
||||
/// <param name="options">The parsed CLI options.</param>
|
||||
/// <returns>A ResizeBatch instance.</returns>
|
||||
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<ResizeError> Process(Action<int, double> 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<ResizeError> Process(Action<int, double> reportProgress, Settings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
double total = Files.Count;
|
||||
int completed = 0;
|
||||
var errors = new ConcurrentBag<ResizeError>();
|
||||
|
||||
// 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(
|
||||
|
||||
Reference in New Issue
Block a user