diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 2e42b0672..91dc87d44 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -337,6 +337,13 @@ "image_edit.count": "الصور: {0}", "image_edit.hint": "اختر الحجم والتنسيق والتدوير لهذه الصور. يحتفظ Filee باختياراتك للمرة القادمة.", "image_edit.convert": "تحويل", + "image_edit.width": "العرض", + "image_edit.height": "الارتفاع", + "image_edit.unit": "الوحدة", + "image_edit.unit.Pixels": "بكسل", + "image_edit.unit.Centimetres": "سم", + "image_edit.unit.Millimetres": "مم", + "image_edit.unit.Inches": "بوصة", "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 9ff6b6d66..3762c01e8 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -337,6 +337,13 @@ "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", + "image_edit.width": "Breite", + "image_edit.height": "Höhe", + "image_edit.unit": "Einheit", + "image_edit.unit.Pixels": "px", + "image_edit.unit.Centimetres": "cm", + "image_edit.unit.Millimetres": "mm", + "image_edit.unit.Inches": "Zoll", "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 5d09bbf75..299f92495 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -347,6 +347,13 @@ "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", + "image_edit.width": "Width", + "image_edit.height": "Height", + "image_edit.unit": "Unit", + "image_edit.unit.Pixels": "px", + "image_edit.unit.Centimetres": "cm", + "image_edit.unit.Millimetres": "mm", + "image_edit.unit.Inches": "inches", "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 048ceac5d..b90960f44 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -337,6 +337,13 @@ "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", + "image_edit.width": "Ancho", + "image_edit.height": "Alto", + "image_edit.unit": "Unidad", + "image_edit.unit.Pixels": "px", + "image_edit.unit.Centimetres": "cm", + "image_edit.unit.Millimetres": "mm", + "image_edit.unit.Inches": "pulgadas", "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 7b517140f..a8042f083 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -347,6 +347,13 @@ "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", + "image_edit.width": "Largeur", + "image_edit.height": "Hauteur", + "image_edit.unit": "Unité", + "image_edit.unit.Pixels": "px", + "image_edit.unit.Centimetres": "cm", + "image_edit.unit.Millimetres": "mm", + "image_edit.unit.Inches": "pouces", "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 e44149bfe..30c6c4b8f 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -337,6 +337,13 @@ "image_edit.count": "{0} इमेज", "image_edit.hint": "इन इमेज का आकार, फ़ॉर्मेट और घुमाव चुनें। Filee अगली बार के लिए आपकी पसंद याद रखता है।", "image_edit.convert": "कन्वर्ट करें", + "image_edit.width": "चौड़ाई", + "image_edit.height": "ऊँचाई", + "image_edit.unit": "इकाई", + "image_edit.unit.Pixels": "px", + "image_edit.unit.Centimetres": "cm", + "image_edit.unit.Millimetres": "mm", + "image_edit.unit.Inches": "इंच", "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 cc0d2b3e3..5fcf01dbc 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -337,6 +337,13 @@ "image_edit.count": "{0} 枚の画像", "image_edit.hint": "この画像のサイズ、形式、回転を選んでください。選んだ内容は次回も使われます。", "image_edit.convert": "変換", + "image_edit.width": "幅", + "image_edit.height": "高さ", + "image_edit.unit": "単位", + "image_edit.unit.Pixels": "px", + "image_edit.unit.Centimetres": "cm", + "image_edit.unit.Millimetres": "mm", + "image_edit.unit.Inches": "インチ", "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 d5f1e16e4..7a69bc8df 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -347,6 +347,13 @@ "image_edit.count": "이미지 {0}개", "image_edit.hint": "이 이미지들의 크기, 형식, 회전을 고르세요. 고른 값은 다음에도 그대로 쓰여요.", "image_edit.convert": "변환", + "image_edit.width": "너비", + "image_edit.height": "높이", + "image_edit.unit": "단위", + "image_edit.unit.Pixels": "px", + "image_edit.unit.Centimetres": "cm", + "image_edit.unit.Millimetres": "mm", + "image_edit.unit.Inches": "인치", "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 5e8926eee..2969d6c4a 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -337,6 +337,13 @@ "image_edit.count": "छविहरू: {0}", "image_edit.hint": "यी छविहरूको आकार, ढाँचा र घुमाइ छान्नुहोस्। Filee ले अर्को पटकका लागि तपाईंको छनोट सम्झन्छ।", "image_edit.convert": "रूपान्तरण गर्नुहोस्", + "image_edit.width": "चौडाइ", + "image_edit.height": "उचाइ", + "image_edit.unit": "एकाइ", + "image_edit.unit.Pixels": "px", + "image_edit.unit.Centimetres": "cm", + "image_edit.unit.Millimetres": "mm", + "image_edit.unit.Inches": "इन्च", "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 8f50edccb..32813c2af 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -347,6 +347,13 @@ "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", + "image_edit.width": "Szerokość", + "image_edit.height": "Wysokość", + "image_edit.unit": "Jednostka", + "image_edit.unit.Pixels": "px", + "image_edit.unit.Centimetres": "cm", + "image_edit.unit.Millimetres": "mm", + "image_edit.unit.Inches": "cale", "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 9b96f02f8..a6d2ff697 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -347,6 +347,13 @@ "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", + "image_edit.width": "Largura", + "image_edit.height": "Altura", + "image_edit.unit": "Unidade", + "image_edit.unit.Pixels": "px", + "image_edit.unit.Centimetres": "cm", + "image_edit.unit.Millimetres": "mm", + "image_edit.unit.Inches": "polegadas", "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 41c27c756..f140131ef 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -347,6 +347,13 @@ "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", + "image_edit.width": "Largura", + "image_edit.height": "Altura", + "image_edit.unit": "Unidade", + "image_edit.unit.Pixels": "px", + "image_edit.unit.Centimetres": "cm", + "image_edit.unit.Millimetres": "mm", + "image_edit.unit.Inches": "polegadas", "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 e1a24eb79..59ea5e03c 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -347,6 +347,13 @@ "image_edit.count": "Изображений: {0}", "image_edit.hint": "Выберите размер, формат и поворот для этих изображений. Filee запомнит выбор на следующий раз.", "image_edit.convert": "Преобразовать", + "image_edit.width": "Ширина", + "image_edit.height": "Высота", + "image_edit.unit": "Единицы", + "image_edit.unit.Pixels": "пикс.", + "image_edit.unit.Centimetres": "см", + "image_edit.unit.Millimetres": "мм", + "image_edit.unit.Inches": "дюймы", "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 536d044e9..69cc8d144 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -337,6 +337,13 @@ "image_edit.count": "รูปภาพ {0} รูป", "image_edit.hint": "เลือกขนาด รูปแบบ และการหมุนของรูปภาพเหล่านี้ Filee จะจำตัวเลือกไว้ใช้ครั้งหน้า", "image_edit.convert": "แปลง", + "image_edit.width": "ความกว้าง", + "image_edit.height": "ความสูง", + "image_edit.unit": "หน่วย", + "image_edit.unit.Pixels": "พิกเซล", + "image_edit.unit.Centimetres": "ซม.", + "image_edit.unit.Millimetres": "มม.", + "image_edit.unit.Inches": "นิ้ว", "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 821b201ee..8003a8765 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -337,6 +337,13 @@ "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", + "image_edit.width": "Genişlik", + "image_edit.height": "Yükseklik", + "image_edit.unit": "Birim", + "image_edit.unit.Pixels": "px", + "image_edit.unit.Centimetres": "cm", + "image_edit.unit.Millimetres": "mm", + "image_edit.unit.Inches": "inç", "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 8d069c66b..c0b03fa91 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -347,6 +347,13 @@ "image_edit.count": "{0} 张图片", "image_edit.hint": "为这些图片选择尺寸、格式和旋转。Filee 会记住你的选择,下次继续使用。", "image_edit.convert": "转换", + "image_edit.width": "宽度", + "image_edit.height": "高度", + "image_edit.unit": "单位", + "image_edit.unit.Pixels": "像素", + "image_edit.unit.Centimetres": "厘米", + "image_edit.unit.Millimetres": "毫米", + "image_edit.unit.Inches": "英寸", "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 5d5551093..6c7e00ebd 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -347,6 +347,13 @@ "image_edit.count": "{0} 張圖片", "image_edit.hint": "為這些圖片選擇尺寸、格式和旋轉。Filee 會記住你的選擇,下次繼續使用。", "image_edit.convert": "轉換", + "image_edit.width": "寬度", + "image_edit.height": "高度", + "image_edit.unit": "單位", + "image_edit.unit.Pixels": "像素", + "image_edit.unit.Centimetres": "公分", + "image_edit.unit.Millimetres": "公釐", + "image_edit.unit.Inches": "英吋", "preset.grayscale": "灰階", "preset.remove_background": "去除背景", "preset.trim_transparent": "裁掉透明邊框", diff --git a/src/Filee.App/ViewModels/ImageEditViewModel.cs b/src/Filee.App/ViewModels/ImageEditViewModel.cs index d2b992d2a..449b62521 100644 --- a/src/Filee.App/ViewModels/ImageEditViewModel.cs +++ b/src/Filee.App/ViewModels/ImageEditViewModel.cs @@ -60,10 +60,21 @@ public ImageEditViewModel(IReadOnlyList pictures, Preset tool, ILocalize 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(); + Units = Enum.GetValues().Select(v => new Choice(v, loc[$"image_edit.unit.{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]; + // A free width × height is stored as CustomSize with Resize None (as presets store it, #576). + _resize = image.CustomSize is not null + ? ResizeModes.First(r => r.Value == ResizeMode.Custom) + : ResizeModes.FirstOrDefault(r => r.Value == image.Resize) ?? ResizeModes[0]; + // Until one is chosen: the shape kept, so a first try never squeezes a photo. + var custom = image.CustomSize ?? new CustomImageSize { LockAspect = true }; + _unit = Units.FirstOrDefault(u => u.Value == custom.Unit) ?? Units[0]; + _dpi = Math.Clamp(custom.Dpi, CustomImageSize.MinDpi, CustomImageSize.MaxDpi); + _customWidth = (decimal)CustomImageSize.FromPixels(custom.ClampedWidth, _unit.Value, _dpi); + _customHeight = (decimal)CustomImageSize.FromPixels(custom.ClampedHeight, _unit.Value, _dpi); + _lockAspect = custom.LockAspect; _rotation = Rotations.FirstOrDefault(r => r.Value == image.Rotation) ?? Rotations[0]; _flip = Flips.FirstOrDefault(f => f.Value == image.Flip) ?? Flips[0]; _resizePercent = image.ResizePercent; @@ -79,13 +90,14 @@ public ImageEditViewModel(IReadOnlyList pictures, Preset tool, ILocalize public IReadOnlyList> ResizeModes { get; } public IReadOnlyList> Rotations { get; } public IReadOnlyList> Flips { get; } + public IReadOnlyList> Units { get; } [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowQuality), nameof(SizeAfter))] private Choice _target; [ObservableProperty] - [NotifyPropertyChangedFor(nameof(ShowPercent), nameof(ShowLongEdge), nameof(ShowWidth), nameof(SizeAfter))] + [NotifyPropertyChangedFor(nameof(ShowPercent), nameof(ShowLongEdge), nameof(ShowWidth), nameof(ShowCustom), nameof(ShowDpi), nameof(SizeAfter))] private Choice _resize; [ObservableProperty] @@ -106,6 +118,30 @@ public ImageEditViewModel(IReadOnlyList pictures, Preset tool, ILocalize [NotifyPropertyChangedFor(nameof(SizeAfter))] private int _width; + /// With : the width, in . + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeAfter))] + private decimal _customWidth; + + /// With : the height, in . + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeAfter))] + private decimal _customHeight; + + /// With : fit inside the width × height instead of stretching to it. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeAfter))] + private bool _lockAspect; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowDpi), nameof(SizeFormat), nameof(SizeAfter))] + private Choice _unit; + + /// The resolution a size in cm, mm or inches becomes pixels at; also written into the pictures. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SizeAfter))] + private int _dpi; + [ObservableProperty] private bool _grayscale; [ObservableProperty] private int _quality; @@ -114,9 +150,31 @@ public ImageEditViewModel(IReadOnlyList pictures, Preset tool, ILocalize 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); + partial void OnUnitChanged(Choice? oldValue, Choice newValue) + { + if (Choices.Restore(oldValue, newValue, v => Unit = v) || oldValue is null || oldValue.Value == newValue.Value) + return; + // The same pixels shown in the new unit, so switching the unit never changes the size by itself. + var dpi = Math.Clamp(Dpi, CustomImageSize.MinDpi, CustomImageSize.MaxDpi); + CustomWidth = (decimal)CustomImageSize.FromPixels(CustomImageSize.ToPixels((double)CustomWidth, oldValue.Value, dpi), newValue.Value, dpi); + CustomHeight = (decimal)CustomImageSize.FromPixels(CustomImageSize.ToPixels((double)CustomHeight, oldValue.Value, dpi), newValue.Value, dpi); + } + public bool ShowPercent => Resize.Value == ResizeMode.Percent; public bool ShowLongEdge => Resize.Value == ResizeMode.LongEdge; public bool ShowWidth => Resize.Value == ResizeMode.Width; + public bool ShowCustom => Resize.Value == ResizeMode.Custom; + + /// A size in cm, mm or inches needs a resolution to become pixels. + public bool ShowDpi => ShowCustom && Unit.Value != SizeUnit.Pixels; + + /// The width and height boxes: whole pixels, one decimal for mm, two for cm and inches. + public string SizeFormat => Unit.Value switch + { + SizeUnit.Pixels => "0", + SizeUnit.Millimetres => "0.#", + _ => "0.##", + }; /// /// JPG, WEBP, AVIF and HEIC; with "Same format", when one of the pictures is one of them (a PNG has no quality). @@ -150,8 +208,16 @@ public ImageEditViewModel(IReadOnlyList pictures, Preset tool, ILocalize 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))); + if (options.CustomSize is { Width: > 0, Height: > 0, LockAspect: false } stretched) + { + // Stretched to exactly that size, not scaled. + (width, height) = (stretched.ClampedWidth, stretched.ClampedHeight); + } + else + { + 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) @@ -166,6 +232,27 @@ internal ImageOptions Options() { var image = _tool.Image.Clone(); image.Resize = Resize.Value; + image.CustomSize = null; + image.MinShortEdge = null; + if (Resize.Value == ResizeMode.Custom) + { + // Stored as presets store it, CustomSize with Resize None, so an older version reads the tool (#576). + var dpi = Math.Clamp(Dpi, CustomImageSize.MinDpi, CustomImageSize.MaxDpi); + int Side(decimal value) => + Math.Clamp(CustomImageSize.ToPixels((double)value, Unit.Value, dpi), CustomImageSize.MinSide, CustomImageSize.MaxSide); + image.Resize = ResizeMode.None; + image.CustomSize = new CustomImageSize + { + Width = Side(CustomWidth), + Height = Side(CustomHeight), + LockAspect = LockAspect, + Unit = Unit.Value, + Dpi = dpi, + }; + // A printed size: the pictures say how large they are meant to be printed. + if (Unit.Value != SizeUnit.Pixels) + image.Dpi = dpi; + } image.ResizePercent = Math.Clamp(ResizePercent, 1, 1000); image.LongEdge = Math.Clamp(LongEdge, 16, 20000); image.Width = Math.Clamp(Width, 16, 20000); diff --git a/src/Filee.App/Views/ImageEditWindow.axaml b/src/Filee.App/Views/ImageEditWindow.axaml index 2dff47d16..6e9c38a1f 100644 --- a/src/Filee.App/Views/ImageEditWindow.axaml +++ b/src/Filee.App/Views/ImageEditWindow.axaml @@ -48,6 +48,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -77,7 +106,7 @@ - + diff --git a/src/Filee.Core/Presets/ImageEditTool.cs b/src/Filee.Core/Presets/ImageEditTool.cs index 5814b8dcb..fbb2d4f04 100644 --- a/src/Filee.Core/Presets/ImageEditTool.cs +++ b/src/Filee.Core/Presets/ImageEditTool.cs @@ -20,10 +20,11 @@ public static class ImageEditTool public static readonly IReadOnlySet LossyTargets = new HashSet { "jpg", "webp", "avif", "heic" }; /// - /// The resizes the window offers, named rather than every : a mode added later (a free - /// width × height, #593) needs fields of its own in the window before it can be offered there. + /// The resizes the window offers, named rather than every : a mode added later needs fields + /// of its own in the window before it can be offered there. is the free width × + /// height, in pixels, cm, mm or inches (#725). /// - public static readonly IReadOnlyList ResizeModes = [ResizeMode.None, ResizeMode.Percent, ResizeMode.LongEdge, ResizeMode.Width]; + public static readonly IReadOnlyList ResizeModes = [ResizeMode.None, ResizeMode.Percent, ResizeMode.LongEdge, ResizeMode.Width, ResizeMode.Custom]; /// True when a file is a picture the tool can open. public static bool CanEdit(string sourceFormat) => @@ -78,6 +79,8 @@ public static void Remember(Preset tool, string targetFormat, ImageOptions image kept.ResizePercent = image.ResizePercent; kept.LongEdge = image.LongEdge; kept.Width = image.Width; + kept.CustomSize = image.Clone().CustomSize; + kept.MinShortEdge = image.MinShortEdge; kept.Rotation = image.Rotation; kept.Flip = image.Flip; kept.Grayscale = image.Grayscale; diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index b9fa4a06e..ffe50ebfd 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -641,7 +641,7 @@ public ImageOptions Clone() copy.IcoSizes = [.. IcoSizes]; copy.HiddenAreas = HiddenAreas?.Select(area => area.Clone()).ToList(); copy.CustomSize = CustomSize is { } size - ? new CustomImageSize { Width = size.Width, Height = size.Height, LockAspect = size.LockAspect } + ? new CustomImageSize { Width = size.Width, Height = size.Height, LockAspect = size.LockAspect, Unit = size.Unit, Dpi = size.Dpi } : null; return copy; } @@ -671,6 +671,16 @@ public sealed class CustomImageSize /// public bool LockAspect { get; set; } + /// + /// The unit the Edit pictures window shows the size in (#725). and stay in + /// pixels whatever it is; a size in cm, mm or inches was turned into pixels at . + /// + [JsonConverter(typeof(LenientEnumJsonConverter))] + public SizeUnit Unit { get; set; } + + /// The resolution a size in cm, mm or inches is turned into pixels at, and written into the picture. + public int Dpi { get; set; } = 300; + /// within the editor's range, so a hand-edited 200000 cannot ask for a picture no /// machine can hold. [JsonIgnore] @@ -679,6 +689,38 @@ public sealed class CustomImageSize /// within the editor's range (see ). [JsonIgnore] public int ClampedHeight => Math.Clamp(Height, MinSide, MaxSide); + + /// The resolutions a size in cm, mm or inches may be turned into pixels at. + public const int MinDpi = 1, MaxDpi = 9600; + + /// How many make an inch; 0 for pixels. + public static double PerInch(SizeUnit unit) => unit switch + { + SizeUnit.Centimetres => 2.54, + SizeUnit.Millimetres => 25.4, + SizeUnit.Inches => 1, + _ => 0, + }; + + /// A length of in pixels at . + public static int ToPixels(double value, SizeUnit unit, int dpi) => + (int)Math.Round(unit == SizeUnit.Pixels ? value : value / PerInch(unit) * dpi, MidpointRounding.AwayFromZero); + + /// + /// in at , rounded as the window shows it: + /// two decimals for cm and inches, one for mm. + /// + public static double FromPixels(int pixels, SizeUnit unit, int dpi) => + unit == SizeUnit.Pixels ? pixels : Math.Round(pixels * PerInch(unit) / dpi, unit == SizeUnit.Millimetres ? 1 : 2); +} + +/// The unit a is entered in. +public enum SizeUnit +{ + Pixels, + Centimetres, + Millimetres, + Inches, } /// Page size used when images are placed into a PDF. diff --git a/tests/Filee.App.Tests/ImageEditWindowTests.cs b/tests/Filee.App.Tests/ImageEditWindowTests.cs index 073b69439..edb7b15b3 100644 --- a/tests/Filee.App.Tests/ImageEditWindowTests.cs +++ b/tests/Filee.App.Tests/ImageEditWindowTests.cs @@ -105,9 +105,99 @@ public void The_size_after_is_worked_out_as_the_encoder_does() [AvaloniaFact] public void The_window_offers_the_resizes_it_has_fields_for() { - // A resize mode added later (#593's free width × height) needs its fields here before it is offered. + // A resize mode added later needs its fields here before it is offered: the free width × height has them (#725). WithWindow(["icon.png"], (_, vm, _) => - Assert.Equal([ResizeMode.None, ResizeMode.Percent, ResizeMode.LongEdge, ResizeMode.Width], vm.ResizeModes.Select(r => r.Value))); + Assert.Equal([ResizeMode.None, ResizeMode.Percent, ResizeMode.LongEdge, ResizeMode.Width, ResizeMode.Custom], + vm.ResizeModes.Select(r => r.Value))); + } + + public static TheoryData SizeLooks => new() + { + { "en", false }, { "en", true }, { "ko", false }, { "ar", false }, { "ru", false }, { "de", true }, { "th", false }, + }; + + [AvaloniaTheory] + [MemberData(nameof(SizeLooks))] + public void The_width_and_height_render(string language, bool dark) + { + WithWindow(["passport.jpg"], (window, vm, _) => + { + vm.Resize = vm.ResizeModes.Single(r => r.Value == ResizeMode.Custom); + vm.Unit = vm.Units.Single(u => u.Value == SizeUnit.Centimetres); + vm.CustomWidth = 3.5m; + vm.CustomHeight = 4.5m; + Pump(); + Assert.True(window.FindControl("DpiBox")!.IsEffectivelyVisible); + Assert.All(window.GetVisualDescendants().OfType(), box => Assert.False(box.ShowButtonSpinner)); + Save(window, $"window-image-edit-size-{language}-{(dark ? "dark" : "light")}.png"); + }, language: language, dark: dark); + } + + [AvaloniaFact] + public void A_width_and_height_in_pixels_stretch_or_fit() + { + WithWindow(["icon.png"], (window, vm, starts) => + { + vm.Resize = vm.ResizeModes.Single(r => r.Value == ResizeMode.Custom); + Assert.True(vm.ShowCustom); + Assert.False(vm.ShowDpi); // pixels need no resolution + Assert.False(window.FindControl("DpiBox")!.IsEffectivelyVisible); + Assert.True(vm.LockAspect); // the shape is kept until the user says otherwise + + vm.CustomWidth = 100; + vm.CustomHeight = 100; + Assert.Equal("100 × 50", vm.SizeAfter); // 512 × 256 fitted inside + vm.LockAspect = false; + Assert.Equal("100 × 100", vm.SizeAfter); // stretched to exactly that + + vm.Convert(); + + var image = Assert.Single(starts).Preset.Image; + Assert.Equal(ResizeMode.None, image.Resize); // stored as presets store it, readable by an older version + Assert.Equal((100, 100, false, SizeUnit.Pixels), (image.CustomSize!.Width, image.CustomSize.Height, image.CustomSize.LockAspect, image.CustomSize.Unit)); + Assert.Null(image.Dpi); // the source's resolution is kept + }); + } + + [AvaloniaFact] + public void A_printed_size_becomes_pixels_at_the_dpi_and_the_pictures_carry_it() + { + WithWindow(["icon.png"], (_, vm, starts) => + { + vm.Resize = vm.ResizeModes.Single(r => r.Value == ResizeMode.Custom); + vm.LockAspect = false; + vm.Unit = vm.Units.Single(u => u.Value == SizeUnit.Centimetres); + Assert.True(vm.ShowDpi); + Assert.Equal(300, vm.Dpi); + vm.CustomWidth = 3.5m; // a passport photo, 35 × 45 mm + vm.CustomHeight = 4.5m; + Assert.Equal("413 × 531", vm.SizeAfter); + vm.Dpi = 600; + Assert.Equal("827 × 1063", vm.SizeAfter); + + vm.Convert(); + + var image = Assert.Single(starts).Preset.Image; + Assert.Equal((827, 1063, SizeUnit.Centimetres, 600), (image.CustomSize!.Width, image.CustomSize.Height, image.CustomSize.Unit, image.CustomSize.Dpi)); + Assert.Equal(600, image.Dpi); + }); + } + + [AvaloniaFact] + public void Switching_the_unit_keeps_the_size() + { + WithWindow(["icon.png"], (_, vm, _) => + { + vm.Resize = vm.ResizeModes.Single(r => r.Value == ResizeMode.Custom); + vm.CustomWidth = 600; + vm.CustomHeight = 300; + vm.Unit = vm.Units.Single(u => u.Value == SizeUnit.Inches); + Assert.Equal((2m, 1m, "0.##"), (vm.CustomWidth, vm.CustomHeight, vm.SizeFormat)); + vm.Unit = vm.Units.Single(u => u.Value == SizeUnit.Millimetres); + Assert.Equal((50.8m, 25.4m, "0.#"), (vm.CustomWidth, vm.CustomHeight, vm.SizeFormat)); + vm.Unit = vm.Units.Single(u => u.Value == SizeUnit.Pixels); + Assert.Equal((600m, 300m, "0"), (vm.CustomWidth, vm.CustomHeight, vm.SizeFormat)); + }); } [AvaloniaFact] @@ -139,7 +229,7 @@ public void Enter_converts_from_a_list_and_escape_closes() }); WithWindow(["icon.png"], (window, _, starts) => { - var grayscale = window.GetVisualDescendants().OfType().Single(); + var grayscale = window.FindControl("GrayscaleBox")!; grayscale.Focus(); Press(window, Key.Enter); Assert.Single(starts); @@ -186,6 +276,7 @@ public void The_choices_are_remembered_in_the_tool() vm.Convert(); }, saved); Assert.Equal(("webp", ResizeMode.Width, 640), (saved.TargetFormat, saved.Image.Resize, saved.Image.Width)); + Assert.Null(saved.Image.CustomSize); // Opened again: as left. WithWindow(["icon.png"], (_, vm, _) => @@ -193,6 +284,24 @@ public void The_choices_are_remembered_in_the_tool() Assert.Equal("webp", vm.Target.Value); Assert.Equal((ResizeMode.Width, 640), (vm.Resize.Value, vm.Width)); Assert.True(vm.ShowWidth); + + // A printed size is kept in its unit and at its resolution. + vm.Resize = vm.ResizeModes.Single(r => r.Value == ResizeMode.Custom); + vm.Unit = vm.Units.Single(u => u.Value == SizeUnit.Millimetres); + vm.Dpi = 350; + vm.CustomWidth = 35; + vm.CustomHeight = 45; + vm.LockAspect = false; + vm.Convert(); + }, saved); + Assert.Equal((ResizeMode.None, 482, 620, SizeUnit.Millimetres, 350), + (saved.Image.Resize, saved.Image.CustomSize!.Width, saved.Image.CustomSize.Height, saved.Image.CustomSize.Unit, saved.Image.CustomSize.Dpi)); + Assert.Null(saved.Image.Dpi); // only the conversions write it; the tool keeps its own + + WithWindow(["icon.png"], (_, vm, _) => + { + Assert.Equal((ResizeMode.Custom, SizeUnit.Millimetres, 350, 35m, 45m, false), + (vm.Resize.Value, vm.Unit.Value, vm.Dpi, vm.CustomWidth, vm.CustomHeight, vm.LockAspect)); }, saved); } finally diff --git a/tests/Filee.Core.Tests/ImageEditToolTests.cs b/tests/Filee.Core.Tests/ImageEditToolTests.cs index be8841cf6..a403dfae3 100644 --- a/tests/Filee.Core.Tests/ImageEditToolTests.cs +++ b/tests/Filee.Core.Tests/ImageEditToolTests.cs @@ -68,6 +68,39 @@ public void The_choices_are_kept_in_the_tool_and_the_rest_of_it_stays() Assert.True(tool.IsImageEditTool); } + [Fact] + public void A_width_and_height_is_kept_in_the_tool_with_its_unit_and_survives_saving() + { + var tool = Tool(); + var image = tool.Image.Clone(); + image.CustomSize = new CustomImageSize { Width = 413, Height = 531, Unit = SizeUnit.Centimetres, Dpi = 300 }; + ImageEditTool.Remember(tool, "jpg", image); + image.CustomSize.Width = 1; // the tool has its own copy + + Assert.Equal((413, SizeUnit.Centimetres, 300), (tool.Image.CustomSize!.Width, tool.Image.CustomSize.Unit, tool.Image.CustomSize.Dpi)); + var clone = tool.Image.Clone().CustomSize!; + Assert.Equal((SizeUnit.Centimetres, 300), (clone.Unit, clone.Dpi)); + + var json = JsonSerializer.Serialize(new List { tool }, FileeJsonContext.Default.ListPreset); + var read = JsonSerializer.Deserialize(json, FileeJsonContext.Default.ListPreset)!.Single().Image.CustomSize!; + Assert.Equal((413, 531, SizeUnit.Centimetres, 300), (read.Width, read.Height, read.Unit, read.Dpi)); + + // A unit a later version adds reads as pixels, instead of the whole presets file failing to read. + var later = json.Replace("\"Centimetres\"", "\"Points\"", StringComparison.Ordinal); + Assert.Equal(SizeUnit.Pixels, JsonSerializer.Deserialize(later, FileeJsonContext.Default.ListPreset)!.Single().Image.CustomSize!.Unit); + } + + [Theory] + [InlineData(3.5, SizeUnit.Centimetres, 300, 413)] + [InlineData(45, SizeUnit.Millimetres, 300, 531)] + [InlineData(2, SizeUnit.Inches, 300, 600)] + [InlineData(640, SizeUnit.Pixels, 300, 640)] + public void A_printed_size_is_turned_into_pixels_and_back(double value, SizeUnit unit, int dpi, int pixels) + { + Assert.Equal(pixels, CustomImageSize.ToPixels(value, unit, dpi)); + Assert.Equal(value, CustomImageSize.FromPixels(pixels, unit, dpi), 1); + } + [Fact] public void A_picture_keeps_its_format_where_it_can_be_written_else_becomes_png() {