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 }