/ src / modules / imageresizer / ui / Models / ResizeOperation.cs
ResizeOperation.cs
  1  #pragma warning disable IDE0073
  2  // Copyright (c) Brice Lambson
  3  // The Brice Lambson licenses this file to you under the MIT license.
  4  // See the LICENSE file in the project root for more information.  Code forked from Brice Lambson's https://github.com/bricelam/ImageResizer/
  5  #pragma warning restore IDE0073
  6  
  7  using System;
  8  using System.Diagnostics;
  9  using System.Globalization;
 10  using System.IO;
 11  using System.IO.Abstractions;
 12  using System.Linq;
 13  using System.Text;
 14  using System.Windows;
 15  using System.Windows.Media;
 16  using System.Windows.Media.Imaging;
 17  
 18  using ImageResizer.Extensions;
 19  using ImageResizer.Properties;
 20  using ImageResizer.Services;
 21  using ImageResizer.Utilities;
 22  using Microsoft.VisualBasic.FileIO;
 23  
 24  using FileSystem = Microsoft.VisualBasic.FileIO.FileSystem;
 25  
 26  namespace ImageResizer.Models
 27  {
 28      internal class ResizeOperation
 29      {
 30          private readonly IFileSystem _fileSystem = new System.IO.Abstractions.FileSystem();
 31  
 32          private readonly string _file;
 33          private readonly string _destinationDirectory;
 34          private readonly Settings _settings;
 35          private readonly IAISuperResolutionService _aiSuperResolutionService;
 36  
 37          // Cache CompositeFormat for AI error message formatting (CA1863)
 38          private static readonly CompositeFormat _aiErrorFormat = CompositeFormat.Parse(Resources.Error_AiProcessingFailed);
 39  
 40          // Filenames to avoid according to https://learn.microsoft.com/windows/win32/fileio/naming-a-file#file-and-directory-names
 41          private static readonly string[] _avoidFilenames =
 42              {
 43                  "CON", "PRN", "AUX", "NUL",
 44                  "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
 45                  "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
 46              };
 47  
 48          public ResizeOperation(string file, string destinationDirectory, Settings settings, IAISuperResolutionService aiSuperResolutionService = null)
 49          {
 50              _file = file;
 51              _destinationDirectory = destinationDirectory;
 52              _settings = settings;
 53              _aiSuperResolutionService = aiSuperResolutionService ?? NoOpAiSuperResolutionService.Instance;
 54          }
 55  
 56          public void Execute()
 57          {
 58              string path;
 59              using (var inputStream = _fileSystem.File.OpenRead(_file))
 60              {
 61                  var decoder = BitmapDecoder.Create(
 62                      inputStream,
 63                      BitmapCreateOptions.PreservePixelFormat,
 64                      BitmapCacheOption.None);
 65  
 66                  var containerFormat = decoder.CodecInfo.ContainerFormat;
 67  
 68                  var encoder = CreateEncoder(containerFormat);
 69  
 70                  if (decoder.Metadata != null)
 71                  {
 72                      try
 73                      {
 74                          encoder.Metadata = decoder.Metadata;
 75                      }
 76                      catch (InvalidOperationException)
 77                      {
 78                      }
 79                  }
 80  
 81                  if (decoder.Palette != null)
 82                  {
 83                      encoder.Palette = decoder.Palette;
 84                  }
 85  
 86                  foreach (var originalFrame in decoder.Frames)
 87                  {
 88                      var transformedBitmap = Transform(originalFrame);
 89  
 90                      // if the frame was not modified, we should not replace the metadata
 91                      if (transformedBitmap == originalFrame)
 92                      {
 93                          encoder.Frames.Add(originalFrame);
 94                      }
 95                      else
 96                      {
 97                          BitmapMetadata originalMetadata = (BitmapMetadata)originalFrame.Metadata;
 98  
 99  #if DEBUG
100                          Debug.WriteLine($"### Processing metadata of file {_file}");
101                          originalMetadata.PrintsAllMetadataToDebugOutput();
102  #endif
103  
104                          var metadata = GetValidMetadata(originalMetadata, transformedBitmap, containerFormat);
105  
106                          if (_settings.RemoveMetadata && metadata != null)
107                          {
108                              // strip any metadata that doesn't affect rendering
109                              var newMetadata = new BitmapMetadata(metadata.Format);
110  
111                              metadata.CopyMetadataPropertyTo(newMetadata, "System.Photo.Orientation");
112                              metadata.CopyMetadataPropertyTo(newMetadata, "System.Image.ColorSpace");
113  
114                              metadata = newMetadata;
115                          }
116  
117                          var frame = CreateBitmapFrame(transformedBitmap, metadata);
118  
119                          encoder.Frames.Add(frame);
120                      }
121                  }
122  
123                  path = GetDestinationPath(encoder);
124                  _fileSystem.Directory.CreateDirectory(_fileSystem.Path.GetDirectoryName(path));
125                  using (var outputStream = _fileSystem.File.Open(path, FileMode.CreateNew, FileAccess.Write))
126                  {
127                      encoder.Save(outputStream);
128                  }
129              }
130  
131              if (_settings.KeepDateModified)
132              {
133                  _fileSystem.File.SetLastWriteTimeUtc(path, _fileSystem.File.GetLastWriteTimeUtc(_file));
134              }
135  
136              if (_settings.Replace)
137              {
138                  var backup = GetBackupPath();
139                  _fileSystem.File.Replace(path, _file, backup, ignoreMetadataErrors: true);
140                  FileSystem.DeleteFile(backup, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin);
141              }
142          }
143  
144          private BitmapEncoder CreateEncoder(Guid containerFormat)
145          {
146              var createdEncoder = BitmapEncoder.Create(containerFormat);
147              if (!createdEncoder.CanEncode())
148              {
149                  createdEncoder = BitmapEncoder.Create(_settings.FallbackEncoder);
150              }
151  
152              ConfigureEncoder(createdEncoder);
153  
154              return createdEncoder;
155  
156              void ConfigureEncoder(BitmapEncoder encoder)
157              {
158                  switch (encoder)
159                  {
160                      case JpegBitmapEncoder jpegEncoder:
161                          jpegEncoder.QualityLevel = MathHelpers.Clamp(_settings.JpegQualityLevel, 1, 100);
162                          break;
163  
164                      case PngBitmapEncoder pngBitmapEncoder:
165                          pngBitmapEncoder.Interlace = _settings.PngInterlaceOption;
166                          break;
167  
168                      case TiffBitmapEncoder tiffEncoder:
169                          tiffEncoder.Compression = _settings.TiffCompressOption;
170                          break;
171                  }
172              }
173          }
174  
175          private BitmapSource Transform(BitmapSource source)
176          {
177              if (_settings.SelectedSize is AiSize)
178              {
179                  return TransformWithAi(source);
180              }
181  
182              int originalWidth = source.PixelWidth;
183              int originalHeight = source.PixelHeight;
184  
185              // Convert from the chosen size unit to pixels, if necessary.
186              double width = _settings.SelectedSize.GetPixelWidth(originalWidth, source.DpiX);
187              double height = _settings.SelectedSize.GetPixelHeight(originalHeight, source.DpiY);
188  
189              // Swap target width/height dimensions if orientation correction is required.
190              // Ensures that we don't try to fit a landscape image into a portrait box by
191              // distorting it, unless specific Auto/Percent rules are applied.
192              bool canSwapDimensions = _settings.IgnoreOrientation &&
193                  !_settings.SelectedSize.HasAuto &&
194                  _settings.SelectedSize.Unit != ResizeUnit.Percent;
195  
196              if (canSwapDimensions)
197              {
198                  bool isInputLandscape = originalWidth > originalHeight;
199                  bool isInputPortrait = originalHeight > originalWidth;
200                  bool isTargetLandscape = width > height;
201                  bool isTargetPortrait = height > width;
202  
203                  // Swap dimensions if there is a mismatch between input and target.
204                  if ((isInputLandscape && isTargetPortrait) ||
205                      (isInputPortrait && isTargetLandscape))
206                  {
207                      (width, height) = (height, width);
208                  }
209              }
210  
211              double scaleX = width / originalWidth;
212              double scaleY = height / originalHeight;
213  
214              // Normalize scales based on the chosen Fit/Fill mode.
215              if (_settings.SelectedSize.Fit == ResizeFit.Fit)
216              {
217                  // Fit: use the smaller scale to ensure the image fits within the target.
218                  scaleX = Math.Min(scaleX, scaleY);
219                  scaleY = scaleX;
220              }
221              else if (_settings.SelectedSize.Fit == ResizeFit.Fill)
222              {
223                  // Fill: use the larger scale to ensure the target area is fully covered.
224                  // This often results in one dimension overflowing, which is handled by
225                  // cropping later.
226                  scaleX = Math.Max(scaleX, scaleY);
227                  scaleY = scaleX;
228              }
229  
230              // Handle Shrink Only mode.
231              if (_settings.ShrinkOnly && _settings.SelectedSize.Unit != ResizeUnit.Percent)
232              {
233                  // Shrink Only mode should never return an image larger than the original.
234                  if (scaleX > 1 || scaleY > 1)
235                  {
236                      return source;
237                  }
238  
239                  // Allow for crop-only when in Fill mode.
240                  // At this point, the scale is <= 1.0. In Fill mode, it is possible for
241                  // the scale to be 1.0 (no resize needed) while the target dimensions are
242                  // smaller than the originals, requiring a crop.
243                  bool isFillCropRequired = _settings.SelectedSize.Fit == ResizeFit.Fill &&
244                      (originalWidth > width || originalHeight > height);
245  
246                  // If the scale is exactly 1.0 and a crop isn't required, we return the
247                  // original image to prevent a re-encode.
248                  if (scaleX == 1 && scaleY == 1 && !isFillCropRequired)
249                  {
250                      return source;
251                  }
252              }
253  
254              // Apply the scaling.
255              var scaledBitmap = new TransformedBitmap(source, new ScaleTransform(scaleX, scaleY));
256  
257              // Apply the centered crop for Fill mode, if necessary. Applies when Fill
258              // mode caused the scaled image to exceed the target dimensions.
259              if (_settings.SelectedSize.Fit == ResizeFit.Fill
260                  && (scaledBitmap.PixelWidth > width
261                  || scaledBitmap.PixelHeight > height))
262              {
263                  int x = (int)(((originalWidth * scaleX) - width) / 2);
264                  int y = (int)(((originalHeight * scaleY) - height) / 2);
265  
266                  return new CroppedBitmap(scaledBitmap, new Int32Rect(x, y, (int)width, (int)height));
267              }
268  
269              return scaledBitmap;
270          }
271  
272          private BitmapSource TransformWithAi(BitmapSource source)
273          {
274              try
275              {
276                  var result = _aiSuperResolutionService.ApplySuperResolution(
277                      source,
278                      _settings.AiSize.Scale,
279                      _file);
280  
281                  if (result == null)
282                  {
283                      throw new InvalidOperationException(Properties.Resources.Error_AiConversionFailed);
284                  }
285  
286                  return result;
287              }
288              catch (Exception ex)
289              {
290                  // Wrap the exception with a localized message
291                  // This will be caught by ResizeBatch.Process() and displayed to the user
292                  var errorMessage = string.Format(CultureInfo.CurrentCulture, _aiErrorFormat, ex.Message);
293                  throw new InvalidOperationException(errorMessage, ex);
294              }
295          }
296  
297          /// <summary>
298          /// Checks original metadata by writing an image containing the given metadata into a memory stream.
299          /// In case of errors, we try to rebuild the metadata object and check again.
300          /// We return null if we were not able to get hold of valid metadata.
301          /// </summary>
302          private BitmapMetadata GetValidMetadata(BitmapMetadata originalMetadata, BitmapSource transformedBitmap, Guid containerFormat)
303          {
304              if (originalMetadata == null)
305              {
306                  return null;
307              }
308  
309              // Check if the original metadata is valid
310              var frameWithOriginalMetadata = CreateBitmapFrame(transformedBitmap, originalMetadata);
311              if (EnsureFrameIsValid(frameWithOriginalMetadata))
312              {
313                  return originalMetadata;
314              }
315  
316              // Original metadata was invalid. We try to rebuild the metadata object from the scratch and discard invalid metadata fields
317              var recreatedMetadata = BuildMetadataFromTheScratch(originalMetadata);
318              var frameWithRecreatedMetadata = CreateBitmapFrame(transformedBitmap, recreatedMetadata);
319              if (EnsureFrameIsValid(frameWithRecreatedMetadata))
320              {
321                  return recreatedMetadata;
322              }
323  
324              // Seems like we have an invalid metadata object. ImageResizer will fail when trying to write the image to disk. We discard all metadata to be able to save the image.
325              return null;
326  
327              // The safest way to check if the metadata object is valid is to call Save() on the encoder.
328              // I tried other ways to check if metadata is valid (like calling Clone() on the metadata object) but this was not reliable resulting in a few github issues.
329              bool EnsureFrameIsValid(BitmapFrame frameToBeChecked)
330              {
331                  try
332                  {
333                      var encoder = CreateEncoder(containerFormat);
334                      encoder.Frames.Add(frameToBeChecked);
335                      using (var testStream = new MemoryStream())
336                      {
337                          encoder.Save(testStream);
338                      }
339  
340                      return true;
341                  }
342                  catch (Exception)
343                  {
344                      return false;
345                  }
346              }
347          }
348  
349          /// <summary>
350          /// Read all metadata and build up metadata object from the scratch. Discard invalid (unreadable/unwritable) metadata.
351          /// </summary>
352          private static BitmapMetadata BuildMetadataFromTheScratch(BitmapMetadata originalMetadata)
353          {
354              try
355              {
356                  var metadata = new BitmapMetadata(originalMetadata.Format);
357                  var listOfMetadata = originalMetadata.GetListOfMetadata();
358                  foreach (var (metadataPath, value) in listOfMetadata)
359                  {
360                      if (value is BitmapMetadata bitmapMetadata)
361                      {
362                          var innerMetadata = new BitmapMetadata(bitmapMetadata.Format);
363                          metadata.SetQuerySafe(metadataPath, innerMetadata);
364                      }
365                      else
366                      {
367                          metadata.SetQuerySafe(metadataPath, value);
368                      }
369                  }
370  
371                  return metadata;
372              }
373              catch (ArgumentException ex)
374              {
375                  Debug.WriteLine(ex);
376  
377                  return null;
378              }
379          }
380  
381          private static BitmapFrame CreateBitmapFrame(BitmapSource transformedBitmap, BitmapMetadata metadata)
382          {
383              return BitmapFrame.Create(
384                  transformedBitmap,
385                  thumbnail: null, /* should be null, see #15413 */
386                  metadata,
387                  colorContexts: null /* should be null, see #14866 */ );
388          }
389  
390          private string GetDestinationPath(BitmapEncoder encoder)
391          {
392              var directory = _destinationDirectory ?? _fileSystem.Path.GetDirectoryName(_file);
393              var originalFileName = _fileSystem.Path.GetFileNameWithoutExtension(_file);
394  
395              var supportedExtensions = encoder.CodecInfo.FileExtensions.Split(',');
396              var extension = _fileSystem.Path.GetExtension(_file);
397              if (!supportedExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
398              {
399                  extension = supportedExtensions.FirstOrDefault();
400              }
401  
402              // Remove directory characters from the size's name.
403              // For AI Size, use the scale display (e.g., "2×") instead of the full name
404              string sizeName = _settings.SelectedSize is AiSize aiSize
405                  ? aiSize.ScaleDisplay
406                  : _settings.SelectedSize.Name;
407              string sizeNameSanitized = sizeName
408                  .Replace('\\', '_')
409                  .Replace('/', '_');
410  
411              // Using CurrentCulture since this is user facing
412              var selectedWidth = _settings.SelectedSize is AiSize ? encoder.Frames[0].PixelWidth : _settings.SelectedSize.Width;
413              var selectedHeight = _settings.SelectedSize is AiSize ? encoder.Frames[0].PixelHeight : _settings.SelectedSize.Height;
414              var fileName = string.Format(
415                  CultureInfo.CurrentCulture,
416                  _settings.FileNameFormat,
417                  originalFileName,
418                  sizeNameSanitized,
419                  selectedWidth,
420                  selectedHeight,
421                  encoder.Frames[0].PixelWidth,
422                  encoder.Frames[0].PixelHeight);
423  
424              // Remove invalid characters from the final file name.
425              fileName = fileName
426                  .Replace(':', '_')
427                  .Replace('*', '_')
428                  .Replace('?', '_')
429                  .Replace('"', '_')
430                  .Replace('<', '_')
431                  .Replace('>', '_')
432                  .Replace('|', '_');
433  
434              // Avoid creating not recommended filenames
435              if (_avoidFilenames.Contains(fileName.ToUpperInvariant()))
436              {
437                  fileName = fileName + "_";
438              }
439  
440              var path = _fileSystem.Path.Combine(directory, fileName + extension);
441              var uniquifier = 1;
442              while (_fileSystem.File.Exists(path))
443              {
444                  path = _fileSystem.Path.Combine(directory, fileName + " (" + uniquifier++ + ")" + extension);
445              }
446  
447              return path;
448          }
449  
450          private string GetBackupPath()
451          {
452              var directory = _fileSystem.Path.GetDirectoryName(_file);
453              var fileName = _fileSystem.Path.GetFileNameWithoutExtension(_file);
454              var extension = _fileSystem.Path.GetExtension(_file);
455  
456              var path = _fileSystem.Path.Combine(directory, fileName + ".bak" + extension);
457              var uniquifier = 1;
458              while (_fileSystem.File.Exists(path))
459              {
460                  path = _fileSystem.Path.Combine(directory, fileName + " (" + uniquifier++ + ")" + ".bak" + extension);
461              }
462  
463              return path;
464          }
465      }
466  }