From 673cd5aba358465f1579e2efc5d94c7e7a1a8c99 Mon Sep 17 00:00:00 2001 From: leileizhang Date: Fri, 26 Dec 2025 12:54:47 +0800 Subject: [PATCH] Add standard CLI support for Image Resizer (#44287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 ``` ![imageresize](https://github.com/user-attachments/assets/437fc1c2-b655-4168-9c85-b1561eeef3b4) ## PR Checklist - [ ] Closes: #xxx - [ ] **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 ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .pipelines/ESRPSigning_core.json | 2 + PowerToys.slnx | 4 + doc/devdocs/cli-conventions.md | 93 ++++ .../ImageResizerCLI/ImageResizerCLI.csproj | 28 ++ .../imageresizer/ImageResizerCLI/Program.cs | 50 ++ .../tests/Cli/CliSettingsApplierTests.cs | 320 +++++++++++++ .../tests/Models/CliOptionsTests.cs | 268 +++++++++++ .../tests/Models/ResizeBatchTests.cs | 15 +- src/modules/imageresizer/ui/Cli/CliLogger.cs | 28 ++ .../imageresizer/ui/Cli/CliSettingsApplier.cs | 122 +++++ .../Cli/Commands/ImageResizerRootCommand.cs | 90 ++++ .../ui/Cli/ImageResizerCliExecutor.cs | 124 +++++ .../ui/Cli/Options/DestinationOption.cs | 18 + .../ui/Cli/Options/FileNameOption.cs | 18 + .../ui/Cli/Options/FilesArgument.cs | 17 + .../imageresizer/ui/Cli/Options/FitOption.cs | 18 + .../ui/Cli/Options/HeightOption.cs | 18 + .../imageresizer/ui/Cli/Options/HelpOption.cs | 18 + .../ui/Cli/Options/IgnoreOrientationOption.cs | 18 + .../ui/Cli/Options/KeepDateModifiedOption.cs | 18 + .../ui/Cli/Options/ProgressLinesOption.cs | 18 + .../ui/Cli/Options/QualityOption.cs | 26 ++ .../ui/Cli/Options/RemoveMetadataOption.cs | 18 + .../ui/Cli/Options/ReplaceOption.cs | 18 + .../ui/Cli/Options/ShowConfigOption.cs | 18 + .../ui/Cli/Options/ShrinkOnlyOption.cs | 18 + .../imageresizer/ui/Cli/Options/SizeOption.cs | 26 ++ .../imageresizer/ui/Cli/Options/UnitOption.cs | 18 + .../ui/Cli/Options/WidthOption.cs | 18 + .../imageresizer/ui/ImageResizerUI.csproj | 2 + .../imageresizer/ui/Models/CliOptions.cs | 261 +++++++++++ .../imageresizer/ui/Models/ResizeBatch.cs | 103 +++-- .../ui/Properties/Resources.Designer.cs | 432 ++++++++++++++++++ .../imageresizer/ui/Properties/Resources.resx | 152 ++++++ .../imageresizer/ui/Properties/Settings.cs | 2 + 35 files changed, 2385 insertions(+), 32 deletions(-) create mode 100644 doc/devdocs/cli-conventions.md create mode 100644 src/modules/imageresizer/ImageResizerCLI/ImageResizerCLI.csproj create mode 100644 src/modules/imageresizer/ImageResizerCLI/Program.cs create mode 100644 src/modules/imageresizer/tests/Cli/CliSettingsApplierTests.cs create mode 100644 src/modules/imageresizer/tests/Models/CliOptionsTests.cs create mode 100644 src/modules/imageresizer/ui/Cli/CliLogger.cs create mode 100644 src/modules/imageresizer/ui/Cli/CliSettingsApplier.cs create mode 100644 src/modules/imageresizer/ui/Cli/Commands/ImageResizerRootCommand.cs create mode 100644 src/modules/imageresizer/ui/Cli/ImageResizerCliExecutor.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/DestinationOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/FileNameOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/FilesArgument.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/FitOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/HeightOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/HelpOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/IgnoreOrientationOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/KeepDateModifiedOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/ProgressLinesOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/QualityOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/RemoveMetadataOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/ReplaceOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/ShowConfigOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/ShrinkOnlyOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/SizeOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/UnitOption.cs create mode 100644 src/modules/imageresizer/ui/Cli/Options/WidthOption.cs create mode 100644 src/modules/imageresizer/ui/Models/CliOptions.cs 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);