diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 8738fcda..7f01dc46 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -313,6 +313,11 @@ "clip.cannot_play": "لا يمكن تشغيل المقطع", "preset.half_size": "نصف الحجم", "preset.half_size.donut": "نصف الحجم", + "preset.image_edit": "تعديل الصور", + "preset.image_edit.donut": "تعديل الصور", + "image_edit.count": "الصور: {0}", + "image_edit.hint": "اختر الحجم والتنسيق والتدوير لهذه الصور. يحتفظ Filee باختياراتك للمرة القادمة.", + "image_edit.convert": "تحويل", "preset.grayscale": "تدرّج رمادي", "preset.remove_background": "إزالة الخلفية", "preset.trim_transparent": "قص الحواف الشفافة", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 154a2512..793b68d0 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -313,6 +313,11 @@ "clip.cannot_play": "Der Clip lässt sich nicht abspielen", "preset.half_size": "Halbe Größe", "preset.half_size.donut": "Halbe Größe", + "preset.image_edit": "Bilder bearbeiten", + "preset.image_edit.donut": "Bilder bearbeiten", + "image_edit.count": "{0} Bilder", + "image_edit.hint": "Wähle Größe, Format und Drehung für diese Bilder. Filee merkt sich deine Wahl für das nächste Mal.", + "image_edit.convert": "Konvertieren", "preset.grayscale": "Graustufen", "preset.remove_background": "Hintergrund entfernen", "preset.trim_transparent": "Transparente Ränder abschneiden", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 4cd5e0eb..7b6744b1 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -323,6 +323,11 @@ "preset.half_size": "Half size", "preset.half_size.donut": "Half size", + "preset.image_edit": "Edit pictures", + "preset.image_edit.donut": "Edit pictures", + "image_edit.count": "{0} pictures", + "image_edit.hint": "Choose the size, format and rotation of these pictures. Filee keeps your choices for next time.", + "image_edit.convert": "Convert", "preset.grayscale": "Grayscale", "preset.remove_background": "Remove background", "preset.trim_transparent": "Trim transparent borders", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 02dab483..03287f31 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -313,6 +313,11 @@ "clip.cannot_play": "No se puede reproducir el clip", "preset.half_size": "Reducir 50 %", "preset.half_size.donut": "Reducir 50 %", + "preset.image_edit": "Editar imágenes", + "preset.image_edit.donut": "Editar imágenes", + "image_edit.count": "{0} imágenes", + "image_edit.hint": "Elige el tamaño, el formato y la rotación de estas imágenes. Filee recuerda tu elección para la próxima vez.", + "image_edit.convert": "Convertir", "preset.grayscale": "Grises", "preset.remove_background": "Quitar fondo", "preset.trim_transparent": "Recortar bordes transparentes", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 36e31d0f..240338d1 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -323,6 +323,11 @@ "preset.half_size": "Taille 50 %", "preset.half_size.donut": "Taille 50 %", + "preset.image_edit": "Modifier les images", + "preset.image_edit.donut": "Modifier", + "image_edit.count": "{0} images", + "image_edit.hint": "Choisissez la taille, le format et la rotation de ces images. Filee garde vos choix pour la prochaine fois.", + "image_edit.convert": "Convertir", "preset.grayscale": "Niveaux de gris", "preset.remove_background": "Détourer", "preset.trim_transparent": "Rogner les bords transparents", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 11dad241..464c4eab 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -313,6 +313,11 @@ "clip.cannot_play": "क्लिप नहीं चल सकती", "preset.half_size": "आधा आकार", "preset.half_size.donut": "आधा आकार", + "preset.image_edit": "इमेज संपादित करें", + "preset.image_edit.donut": "संपादन", + "image_edit.count": "{0} इमेज", + "image_edit.hint": "इन इमेज का आकार, फ़ॉर्मेट और घुमाव चुनें। Filee अगली बार के लिए आपकी पसंद याद रखता है।", + "image_edit.convert": "कन्वर्ट करें", "preset.grayscale": "ग्रेस्केल", "preset.remove_background": "बैकग्राउंड हटाएँ", "preset.trim_transparent": "पारदर्शी किनारे काटें", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 5298e5df..60248616 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -313,6 +313,11 @@ "clip.cannot_play": "クリップを再生できません", "preset.half_size": "縮小 50%", "preset.half_size.donut": "縮小 50%", + "preset.image_edit": "画像を編集", + "preset.image_edit.donut": "画像を編集", + "image_edit.count": "{0} 枚の画像", + "image_edit.hint": "この画像のサイズ、形式、回転を選んでください。選んだ内容は次回も使われます。", + "image_edit.convert": "変換", "preset.grayscale": "グレースケール", "preset.remove_background": "背景を削除", "preset.trim_transparent": "透明な余白を切り取る", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index bb6eb21d..1a4c664b 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -323,6 +323,11 @@ "preset.half_size": "50% 축소", "preset.half_size.donut": "50% 축소", + "preset.image_edit": "이미지 편집", + "preset.image_edit.donut": "이미지 편집", + "image_edit.count": "이미지 {0}개", + "image_edit.hint": "이 이미지들의 크기, 형식, 회전을 고르세요. 고른 값은 다음에도 그대로 쓰여요.", + "image_edit.convert": "변환", "preset.grayscale": "흑백", "preset.remove_background": "배경 제거", "preset.trim_transparent": "투명 여백 자르기", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 0716ee27..db8564a5 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -313,6 +313,11 @@ "clip.cannot_play": "क्लिप बजाउन सकिँदैन", "preset.half_size": "आधा आकार", "preset.half_size.donut": "आधा आकार", + "preset.image_edit": "छविहरू सम्पादन गर्नुहोस्", + "preset.image_edit.donut": "सम्पादन", + "image_edit.count": "छविहरू: {0}", + "image_edit.hint": "यी छविहरूको आकार, ढाँचा र घुमाइ छान्नुहोस्। Filee ले अर्को पटकका लागि तपाईंको छनोट सम्झन्छ।", + "image_edit.convert": "रूपान्तरण गर्नुहोस्", "preset.grayscale": "ग्रेस्केल", "preset.remove_background": "पृष्ठभूमि हटाउनुहोस्", "preset.trim_transparent": "पारदर्शी किनारा काट्नुहोस्", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 7ea3d913..da56bc39 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -323,6 +323,11 @@ "preset.half_size": "Połowa rozmiaru", "preset.half_size.donut": "Połowa rozmiaru", + "preset.image_edit": "Edytuj obrazy", + "preset.image_edit.donut": "Edytuj obrazy", + "image_edit.count": "Obrazy: {0}", + "image_edit.hint": "Wybierz rozmiar, format i obrót tych obrazów. Filee zapamięta wybór na następny raz.", + "image_edit.convert": "Konwertuj", "preset.grayscale": "Skala szarości", "preset.remove_background": "Usuń tło", "preset.trim_transparent": "Przytnij przezroczyste brzegi", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 140aa954..c3a093db 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -323,6 +323,11 @@ "preset.half_size": "Reduzir 50%", "preset.half_size.donut": "Tamanho 50%", + "preset.image_edit": "Editar imagens", + "preset.image_edit.donut": "Editar imagens", + "image_edit.count": "{0} imagens", + "image_edit.hint": "Escolha o tamanho, o formato e a rotação dessas imagens. O Filee guarda suas escolhas para a próxima vez.", + "image_edit.convert": "Converter", "preset.grayscale": "Tons de cinza", "preset.remove_background": "Remover fundo", "preset.trim_transparent": "Cortar bordas transparentes", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 05327916..607271c0 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -323,6 +323,11 @@ "preset.half_size": "Metade do tamanho", "preset.half_size.donut": "Tamanho 50%", + "preset.image_edit": "Editar imagens", + "preset.image_edit.donut": "Editar imagens", + "image_edit.count": "{0} imagens", + "image_edit.hint": "Escolha o tamanho, o formato e a rotação destas imagens. O Filee guarda as suas escolhas para a próxima vez.", + "image_edit.convert": "Converter", "preset.grayscale": "Escala de cinzentos", "preset.remove_background": "Remover fundo", "preset.trim_transparent": "Recortar margens transparentes", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index c6b23149..978c178f 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -323,6 +323,11 @@ "preset.half_size": "Вдвое меньше", "preset.half_size.donut": "Вдвое меньше", + "preset.image_edit": "Изменить изображения", + "preset.image_edit.donut": "Изменить", + "image_edit.count": "Изображений: {0}", + "image_edit.hint": "Выберите размер, формат и поворот для этих изображений. Filee запомнит выбор на следующий раз.", + "image_edit.convert": "Преобразовать", "preset.grayscale": "Оттенки серого", "preset.remove_background": "Убрать фон", "preset.trim_transparent": "Обрезать прозрачные края", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index bd33fbba..b5323b75 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -313,6 +313,11 @@ "clip.cannot_play": "เล่นคลิปนี้ไม่ได้", "preset.half_size": "ขนาดครึ่งหนึ่ง", "preset.half_size.donut": "ขนาดครึ่งหนึ่ง", + "preset.image_edit": "แก้ไขรูปภาพ", + "preset.image_edit.donut": "แก้ไขภาพ", + "image_edit.count": "รูปภาพ {0} รูป", + "image_edit.hint": "เลือกขนาด รูปแบบ และการหมุนของรูปภาพเหล่านี้ Filee จะจำตัวเลือกไว้ใช้ครั้งหน้า", + "image_edit.convert": "แปลง", "preset.grayscale": "ขาวดำ (เกรย์สเกล)", "preset.remove_background": "ลบพื้นหลัง", "preset.trim_transparent": "ตัดขอบโปร่งใส", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 0a0ddf6c..9606065d 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -313,6 +313,11 @@ "clip.cannot_play": "Klip oynatılamıyor", "preset.half_size": "Yarı boyut", "preset.half_size.donut": "Yarı boyut", + "preset.image_edit": "Görselleri düzenle", + "preset.image_edit.donut": "Görselleri düzenle", + "image_edit.count": "{0} görsel", + "image_edit.hint": "Bu görsellerin boyutunu, biçimini ve yönünü seçin. Filee seçimlerinizi bir dahaki sefere hatırlar.", + "image_edit.convert": "Dönüştür", "preset.grayscale": "Gri tonlama", "preset.remove_background": "Arka planı kaldır", "preset.trim_transparent": "Saydam kenarları kırp", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 7f250fbd..82bebf87 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -323,6 +323,11 @@ "preset.half_size": "缩小 50%", "preset.half_size.donut": "缩小 50%", + "preset.image_edit": "编辑图片", + "preset.image_edit.donut": "编辑图片", + "image_edit.count": "{0} 张图片", + "image_edit.hint": "为这些图片选择尺寸、格式和旋转。Filee 会记住你的选择,下次继续使用。", + "image_edit.convert": "转换", "preset.grayscale": "灰度", "preset.remove_background": "去除背景", "preset.trim_transparent": "裁掉透明边框", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 2d8f1978..6ee8c5b2 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -323,6 +323,11 @@ "preset.half_size": "縮小 50%", "preset.half_size.donut": "縮小 50%", + "preset.image_edit": "編輯圖片", + "preset.image_edit.donut": "編輯圖片", + "image_edit.count": "{0} 張圖片", + "image_edit.hint": "為這些圖片選擇尺寸、格式和旋轉。Filee 會記住你的選擇,下次繼續使用。", + "image_edit.convert": "轉換", "preset.grayscale": "灰階", "preset.remove_background": "去除背景", "preset.trim_transparent": "裁掉透明邊框", diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs index b4ee3947..1763d469 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -119,6 +119,12 @@ private PresetJudgement Evaluate(Preset preset, IReadOnlyList format : new(PresetUse.Unusable, _loc["error.no_route"]); } + // Any picture among the files: the others are left out when the window opens. + if (preset.IsImageEditTool) + return formats.Any(f => ImageEditTool.CanEdit(f.Id)) + ? new(PresetUse.Usable, null) + : new(PresetUse.Unusable, _loc["error.images_only"]); + if (preset.IsTool) { if (preset.Id == "tool-sheet-studio") diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index c6c23879..f9acdb40 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -36,8 +36,10 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem private CropOverlayWindow? _cropOverlay; private ClipWindow? _clipWindow; + private ImageEditWindow? _imageEdit; internal ClipWindow? ClipWindow => _clipWindow; + internal ImageEditWindow? ImageEdit => _imageEdit; private readonly HashSet _spreadsheetStudios = []; internal IReadOnlyCollection SpreadsheetStudios => _spreadsheetStudios; @@ -121,13 +123,15 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem public MainWindow? Main => _main; /// - /// Opens the window of a tool preset (crop, cut a clip, the spreadsheet print studio: one per workbook, the PDF page - /// editor) on the files. A tool this version doesn't know opens nothing. + /// Opens the window of a tool preset (crop, cut a clip, edit pictures, the spreadsheet print studio: one per workbook, + /// the PDF page editor) on the files. A tool this version doesn't know opens nothing. /// public void ShowTool(IReadOnlyList files, Preset preset, PixelPoint? near = null) { if (preset.IsClipTool) ShowClipWindow(files, preset, near); + else if (preset.IsImageEditTool) + ShowImageEditWindow(files, preset); // Before the crop: the print studio and the PDF page tools have no Tool of their own, which reads as a crop // ("Crop PDF" is one and opens the crop overlay). else if (preset.Id == "tool-sheet-studio") @@ -173,6 +177,59 @@ public void ShowClipWindow(IReadOnlyList files, Preset preset, PixelPoin win.Activate(); } + /// + /// Opens the "Edit pictures" window on the pictures among the files. Its Convert starts ordinary conversions, shown + /// in the toast. + /// + public void ShowImageEditWindow(IReadOnlyList files, Preset preset) + { + var pictures = files.Where(f => FormatRegistry.Detect(f) is { } format && ImageEditTool.CanEdit(format.Id)).ToList(); + if (pictures.Count == 0) + return; + _imageEdit?.Close(); + var store = services.GetRequiredService(); + var catalog = services.GetRequiredService(); + var conversions = services.GetRequiredService(); + var log = services.GetService>(); + var planner = catalog.CreatePlanner(); + var formats = pictures.Select(path => FormatRegistry.Detect(path)?.Id ?? "").ToList(); + var vm = new ImageEditViewModel(pictures, preset, loc, store, + (path, chosen) => ImageEditTool.TargetFor(FormatRegistry.Detect(path)?.Id ?? "", chosen, planner), + (paths, conversion) => conversions.Start(paths, conversion), PictureSize, log, ImageEditTool.TargetsFrom(formats, planner)); + var win = new ImageEditWindow { DataContext = vm }; + PrepareWindow(win); + win.Closed += (_, _) => + { + if (_imageEdit == win) + _imageEdit = null; + }; + _imageEdit = win; + win.Show(); + win.Activate(); + } + + /// + /// The pixel size of a picture as it is seen, read from its headers: turned as its EXIF orientation says, and the + /// largest of an icon's sizes (the one converted). Null when ImageMagick can't read it. + /// + internal static (int Width, int Height)? PictureSize(string path) + { + try + { + var info = ImageMagick.MagickImageInfo.ReadCollection(path).MaxBy(frame => (long)frame.Width * frame.Height); + if (info is null) + return null; + return info.Orientation is ImageMagick.OrientationType.LeftTop or ImageMagick.OrientationType.RightTop + or ImageMagick.OrientationType.RightBottom or ImageMagick.OrientationType.LeftBottom + ? ((int)info.Height, (int)info.Width) + : ((int)info.Width, (int)info.Height); + } + catch (Exception ex) when (ex is ImageMagick.MagickException or IOException or UnauthorizedAccessException) + { + return null; + } + } + /// Opens the interactive crop overlay for the files. public void ShowCropOverlay(IReadOnlyList files, Preset preset, PixelPoint? near = null) { diff --git a/src/Filee.App/ViewModels/ImageEditViewModel.cs b/src/Filee.App/ViewModels/ImageEditViewModel.cs new file mode 100644 index 00000000..d2b992d2 --- /dev/null +++ b/src/Filee.App/ViewModels/ImageEditViewModel.cs @@ -0,0 +1,207 @@ +// "Edit pictures" (#584): the size, format, turn and colour of the dropped pictures, chosen right before they are +// converted instead of in a preset made for each size. The window opens with the tool's last choices; Convert keeps them +// in the tool and starts ordinary conversions (ImageEditTool), so the toast, cancelling and the output rules work as usual. + +using CommunityToolkit.Mvvm.ComponentModel; +using Filee.Core.Formats; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Core.Settings; +using Filee.Engines.Magick; +using Microsoft.Extensions.Logging; + +namespace Filee.App.ViewModels; + +public sealed partial class ImageEditViewModel : ObservableObject +{ + /// File names listed in the heading's tooltip before "…". + private const int NamesInTip = 12; + + private readonly Preset _tool; + private readonly ILocalizer _loc; + private readonly UserDataStore _store; + private readonly Func _targetFor; + private readonly Action, Preset> _start; + private readonly ILogger? _log; + private readonly (int Width, int Height)? _firstSize; + private readonly IReadOnlyList _sourceFormats; + private readonly bool _keepsLossyFormat; + + /// The pictures to convert (other files are left out by the caller). + /// The "Edit pictures" tool: its last choices, name and output rule. + /// The format a picture (path) is written in for the chosen one (). + /// Starts the conversion of some of the pictures. + /// + /// The pixel size of a picture as it is seen (EXIF orientation applied), null when it can't be read; for the size + /// shown in the window. + /// + /// The formats offered after "Same format" (); all by default. + public ImageEditViewModel(IReadOnlyList pictures, Preset tool, ILocalizer loc, UserDataStore store, + Func targetFor, Action, Preset> start, + Func? sizeOf = null, ILogger? log = null, IReadOnlyList? targets = null) + { + Pictures = pictures; + _tool = tool; + _loc = loc; + _store = store; + _targetFor = targetFor; + _start = start; + _log = log; + _firstSize = pictures.Count > 0 && sizeOf?.Invoke(pictures[0]) is { Width: > 0, Height: > 0 } size ? size : null; + _sourceFormats = pictures.Select(path => FormatRegistry.Detect(path)?.Id ?? "").ToList(); + _keepsLossyFormat = pictures.Where((path, i) => ImageEditTool.LossyTargets.Contains(_sourceFormats[i]) + && targetFor(path, BuiltInData.SameAsSource) == BuiltInData.SameAsSource).Any(); + + Targets = + [ + new Choice(BuiltInData.SameAsSource, loc["presets.same_as_source"]), + .. (targets ?? ImageEditTool.Targets).Select(id => new Choice(id, FormatRegistry.Get(id).DisplayName)), + ]; + ResizeModes = ImageEditTool.ResizeModes.Select(v => new Choice(v, loc[$"presets.resize.{v}"])).ToList(); + Rotations = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.rotation.{v}"])).ToList(); + Flips = Enum.GetValues().Select(v => new Choice(v, loc[$"presets.flip.{v}"])).ToList(); + + var image = tool.Image; + _target = Targets.FirstOrDefault(t => t.Value == tool.TargetFormat) ?? Targets[0]; + _resize = ResizeModes.FirstOrDefault(r => r.Value == image.Resize) ?? ResizeModes[0]; + _rotation = Rotations.FirstOrDefault(r => r.Value == image.Rotation) ?? Rotations[0]; + _flip = Flips.FirstOrDefault(f => f.Value == image.Flip) ?? Flips[0]; + _resizePercent = image.ResizePercent; + _longEdge = image.LongEdge; + _width = image.Width; + _grayscale = image.Grayscale; + _quality = image.Quality; + } + + public IReadOnlyList Pictures { get; } + + public IReadOnlyList> Targets { get; } + public IReadOnlyList> ResizeModes { get; } + public IReadOnlyList> Rotations { get; } + public IReadOnlyList> Flips { get; } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowQuality), nameof(SizeAfter))] + private Choice _target; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowPercent), nameof(ShowLongEdge), nameof(ShowWidth), nameof(SizeAfter))] + private Choice _resize; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeAfter))] + private Choice _rotation; + + [ObservableProperty] private Choice _flip; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeAfter))] + private int _resizePercent; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeAfter))] + private int _longEdge; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeAfter))] + private int _width; + + [ObservableProperty] private bool _grayscale; + [ObservableProperty] private int _quality; + + partial void OnTargetChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Target = v); + partial void OnResizeChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Resize = v); + partial void OnRotationChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Rotation = v); + partial void OnFlipChanged(Choice? oldValue, Choice newValue) => Choices.Restore(oldValue, newValue, v => Flip = v); + + public bool ShowPercent => Resize.Value == ResizeMode.Percent; + public bool ShowLongEdge => Resize.Value == ResizeMode.LongEdge; + public bool ShowWidth => Resize.Value == ResizeMode.Width; + + /// + /// JPG, WEBP, AVIF and HEIC; with "Same format", when one of the pictures is one of them (a PNG has no quality). + /// + public bool ShowQuality => Target.Value == BuiltInData.SameAsSource ? _keepsLossyFormat : ImageEditTool.LossyTargets.Contains(Target.Value); + + /// The first picture's name, or how many pictures there are. + public string Heading => Pictures.Count == 1 ? Path.GetFileName(Pictures[0]) : _loc.Format("image_edit.count", Pictures.Count); + + /// The heading's tooltip: the picture's whole path, or the names of the pictures. + public string HeadingTip => Pictures.Count == 1 + ? Pictures[0] + : string.Join('\n', Pictures.Take(NamesInTip).Select(Path.GetFileName)) + (Pictures.Count > NamesInTip ? "\n…" : ""); + + /// The before → after line is shown: the first picture's size could be read. + public bool ShowSize => _firstSize is not null; + + /// The first picture's size, "512 × 256"; empty when it can't be read. + public string SizeBefore => _firstSize is { } size ? $"{size.Width} × {size.Height}" : ""; + + /// The size the first picture is written in with the choices, "32 × 16"; empty when it can't be read. + public string SizeAfter => SizeOfFirst() is { } size ? $"{size.Width} × {size.Height}" : ""; + + /// + /// The first picture's size once converted, worked out as the encoder does it: turned, resized, brought within what + /// the format stores, and for an icon squared and at most 256 px. + /// + internal (int Width, int Height)? SizeOfFirst() + { + if (_firstSize is not { } size) + return null; + var options = Options(); + var (width, height) = options.Rotation is ImageRotation.Left90 or ImageRotation.Right90 ? (size.Height, size.Width) : size; + var scale = ImageEncoder.ResizeScale(width, height, options); + (width, height) = (Math.Max(1, (int)Math.Round(width * scale)), Math.Max(1, (int)Math.Round(height * scale))); + var target = _targetFor(Pictures[0], Target.Value) is var chosen && chosen == BuiltInData.SameAsSource ? _sourceFormats[0] : chosen; + var fit = Math.Min(1, ImageEncoder.MaxSide(target) / Math.Max(width, height)); + if (fit < 1) + (width, height) = (Math.Max(1, (int)Math.Floor(width * fit)), Math.Max(1, (int)Math.Floor(height * fit))); + if (target == "ico") + width = height = Math.Min(256, Math.Max(width, height)); + return (width, height); + } + + /// The tool's picture options with the window's choices. + internal ImageOptions Options() + { + var image = _tool.Image.Clone(); + image.Resize = Resize.Value; + image.ResizePercent = Math.Clamp(ResizePercent, 1, 1000); + image.LongEdge = Math.Clamp(LongEdge, 16, 20000); + image.Width = Math.Clamp(Width, 16, 20000); + image.Rotation = Rotation.Value; + image.Flip = Flip.Value; + image.Grayscale = Grayscale; + image.Quality = Math.Clamp(Quality, 1, 100); + return image; + } + + /// + /// Keeps the choices in the tool for the next time and starts the conversions: one for each format the pictures are + /// written in (a camera RAW kept in "its own format" becomes PNG). + /// + public void Convert() + { + var image = Options(); + Remember(image); + foreach (var group in Pictures.GroupBy(path => _targetFor(path, Target.Value))) + _start([.. group], ImageEditTool.ConversionOf(_tool, group.Key, image)); + } + + private void Remember(ImageOptions image) + { + // In the tool as saved, and in the one given to this window (maybe the same). + ImageEditTool.Remember(_tool, Target.Value, image); + if (_store.FindPreset(_tool.Id) is not { IsImageEditTool: true } saved) + return; + ImageEditTool.Remember(saved, Target.Value, image); + try + { + _store.SaveLibrary(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log?.LogWarning(ex, "Could not remember the picture edits"); + } + } +} diff --git a/src/Filee.App/Views/ImageEditWindow.axaml b/src/Filee.App/Views/ImageEditWindow.axaml new file mode 100644 index 00000000..2dff47d1 --- /dev/null +++ b/src/Filee.App/Views/ImageEditWindow.axaml @@ -0,0 +1,83 @@ + + + + + +