diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 5c54c4b6..a0ebe89f 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -640,6 +640,7 @@ "presets.resize.LongEdge": "تحديد الحد الأقصى للضلع الأطول", "presets.resize.Width": "تعيين العرض", "presets.resize.Custom": "تعيين العرض والارتفاع", + "presets.resize.MinShortEdge": "تحديد الحد الأدنى للضلع الأقصر", "presets.rotation": "التدوير", "presets.rotation.None": "دون تدوير", "presets.rotation.Right90": "90° لليمين", @@ -654,6 +655,8 @@ "presets.width": "العرض (بكسل)", "presets.height": "الارتفاع (بكسل)", "presets.lock_aspect": "الحفاظ على نسبة الأبعاد", + "presets.min_short_edge": "الحد الأدنى للضلع الأقصر (بكسل)", + "presets.min_short_edge_hint": "تُكبَّر الصور الأصغر حتى يبلغ ضلعها الأقصر هذا الطول، وتبقى الصور الأكبر كما هي.", "presets.dpi": "الدقة (DPI)", "presets.dpi_keep": "الاحتفاظ بها", "presets.keep_metadata": "الاحتفاظ بالبيانات الوصفية (EXIF)", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 6a234a92..26ef3a68 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -640,6 +640,7 @@ "presets.resize.LongEdge": "Lange Kante begrenzen", "presets.resize.Width": "Breite festlegen", "presets.resize.Custom": "Breite und Höhe festlegen", + "presets.resize.MinShortEdge": "Mindestlänge der kurzen Kante", "presets.rotation": "Drehen", "presets.rotation.None": "Nicht drehen", "presets.rotation.Right90": "90° nach rechts", @@ -654,6 +655,8 @@ "presets.width": "Breite (px)", "presets.height": "Höhe (px)", "presets.lock_aspect": "Seitenverhältnis beibehalten", + "presets.min_short_edge": "Kurze Kante mindestens (px)", + "presets.min_short_edge_hint": "Kleinere Bilder werden vergrößert, bis ihre kurze Kante so lang ist. Größere bleiben, wie sie sind.", "presets.dpi": "DPI", "presets.dpi_keep": "Beibehalten", "presets.keep_metadata": "Metadaten beibehalten (EXIF)", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index a168cd0e..da19ff30 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -655,6 +655,7 @@ "presets.resize.LongEdge": "Limit the long edge", "presets.resize.Width": "Set the width", "presets.resize.Custom": "Set width and height", + "presets.resize.MinShortEdge": "Set a minimum short edge", "presets.rotation": "Rotate", "presets.rotation.None": "Don't rotate", "presets.rotation.Right90": "Right 90°", @@ -669,6 +670,8 @@ "presets.width": "Width (px)", "presets.height": "Height (px)", "presets.lock_aspect": "Keep aspect ratio", + "presets.min_short_edge": "Minimum short edge (px)", + "presets.min_short_edge_hint": "Smaller pictures are enlarged until their short edge is this long. Larger ones are left as they are.", "presets.dpi": "DPI", "presets.dpi_keep": "Keep", "presets.keep_metadata": "Keep metadata (EXIF)", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index dea96a59..0cab8c84 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -640,6 +640,7 @@ "presets.resize.LongEdge": "Limitar el lado largo", "presets.resize.Width": "Fijar el ancho", "presets.resize.Custom": "Fijar el ancho y el alto", + "presets.resize.MinShortEdge": "Fijar un lado corto mínimo", "presets.rotation": "Rotar", "presets.rotation.None": "No rotar", "presets.rotation.Right90": "90° a la derecha", @@ -654,6 +655,8 @@ "presets.width": "Ancho (px)", "presets.height": "Alto (px)", "presets.lock_aspect": "Mantener la proporción", + "presets.min_short_edge": "Lado corto mínimo (px)", + "presets.min_short_edge_hint": "Las imágenes más pequeñas se amplían hasta que su lado corto mide esto. Las más grandes se quedan como están.", "presets.dpi": "DPI", "presets.dpi_keep": "Conservar", "presets.keep_metadata": "Conservar los metadatos (EXIF)", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 948a24c1..aedf062c 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -655,6 +655,7 @@ "presets.resize.LongEdge": "Limiter le grand côté", "presets.resize.Width": "Fixer la largeur", "presets.resize.Custom": "Fixer la largeur et la hauteur", + "presets.resize.MinShortEdge": "Imposer un petit côté minimal", "presets.rotation": "Faire pivoter", "presets.rotation.None": "Ne pas faire pivoter", "presets.rotation.Right90": "90° à droite", @@ -669,6 +670,8 @@ "presets.width": "Largeur (px)", "presets.height": "Hauteur (px)", "presets.lock_aspect": "Conserver les proportions", + "presets.min_short_edge": "Petit côté minimal (px)", + "presets.min_short_edge_hint": "Les images plus petites sont agrandies jusqu'à ce que leur petit côté atteigne cette longueur. Les plus grandes restent telles quelles.", "presets.dpi": "DPI", "presets.dpi_keep": "Conserver", "presets.keep_metadata": "Conserver les métadonnées (EXIF)", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index f8f1ea2f..5ee67178 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -640,6 +640,7 @@ "presets.resize.LongEdge": "लंबी भुजा सीमित करें", "presets.resize.Width": "चौड़ाई तय करें", "presets.resize.Custom": "चौड़ाई और ऊँचाई तय करें", + "presets.resize.MinShortEdge": "छोटी भुजा की न्यूनतम लंबाई तय करें", "presets.rotation": "घुमाएँ", "presets.rotation.None": "न घुमाएँ", "presets.rotation.Right90": "दाएँ 90°", @@ -654,6 +655,8 @@ "presets.width": "चौड़ाई (px)", "presets.height": "ऊँचाई (px)", "presets.lock_aspect": "अनुपात बनाए रखें", + "presets.min_short_edge": "छोटी भुजा कम से कम (px)", + "presets.min_short_edge_hint": "छोटी तस्वीरें तब तक बड़ी की जाती हैं जब तक उनकी छोटी भुजा इतनी लंबी न हो जाए। बड़ी तस्वीरें जैसी हैं वैसी ही रहती हैं।", "presets.dpi": "DPI", "presets.dpi_keep": "वही रखें", "presets.keep_metadata": "मेटाडेटा (EXIF) रखें", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 2c897a5d..513e7f78 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -640,6 +640,7 @@ "presets.resize.LongEdge": "長辺の上限を指定", "presets.resize.Width": "幅を指定", "presets.resize.Custom": "幅と高さを指定", + "presets.resize.MinShortEdge": "短辺の下限を指定", "presets.rotation": "回転", "presets.rotation.None": "回転しない", "presets.rotation.Right90": "右に90°", @@ -654,6 +655,8 @@ "presets.width": "幅(px)", "presets.height": "高さ(px)", "presets.lock_aspect": "縦横比を維持", + "presets.min_short_edge": "短辺の下限(px)", + "presets.min_short_edge_hint": "小さい画像は短辺がこの長さになるまで拡大します。大きい画像はそのままです。", "presets.dpi": "DPI", "presets.dpi_keep": "維持", "presets.keep_metadata": "メタデータ(EXIF)を保持", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index bdf82d15..14d33498 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -655,6 +655,7 @@ "presets.resize.LongEdge": "긴 변 제한", "presets.resize.Width": "너비 맞추기", "presets.resize.Custom": "너비와 높이 맞추기", + "presets.resize.MinShortEdge": "짧은 변 최소 길이", "presets.rotation": "회전", "presets.rotation.None": "회전 안 함", "presets.rotation.Right90": "오른쪽 90°", @@ -669,6 +670,8 @@ "presets.width": "너비(px)", "presets.height": "높이(px)", "presets.lock_aspect": "가로세로 비율 유지", + "presets.min_short_edge": "짧은 변 최소 (px)", + "presets.min_short_edge_hint": "더 작은 그림은 짧은 변이 이 길이가 될 때까지 키우고, 큰 그림은 그대로 둬요.", "presets.dpi": "DPI", "presets.dpi_keep": "유지", "presets.keep_metadata": "메타데이터(EXIF) 유지", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index b765447f..23ec771f 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -640,6 +640,7 @@ "presets.resize.LongEdge": "लामो छेउ सीमित गर्नुहोस्", "presets.resize.Width": "चौडाइ निर्धारण गर्नुहोस्", "presets.resize.Custom": "चौडाइ र उचाइ निर्धारण गर्नुहोस्", + "presets.resize.MinShortEdge": "छोटो छेउको न्यूनतम लम्बाइ तोक्नुहोस्", "presets.rotation": "घुमाउनुहोस्", "presets.rotation.None": "नघुमाउनुहोस्", "presets.rotation.Right90": "दायाँ ९०°", @@ -654,6 +655,8 @@ "presets.width": "चौडाइ (px)", "presets.height": "उचाइ (px)", "presets.lock_aspect": "अनुपात कायम राख्नुहोस्", + "presets.min_short_edge": "छोटो छेउ कम्तीमा (px)", + "presets.min_short_edge_hint": "साना तस्बिरहरूलाई छोटो छेउ यति लामो नहुँदासम्म ठूलो बनाइन्छ। ठूला तस्बिरहरू जस्ताको तस्तै रहन्छन्।", "presets.dpi": "DPI", "presets.dpi_keep": "यथावत राख्नुहोस्", "presets.keep_metadata": "मेटाडाटा (EXIF) राख्नुहोस्", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 0bfb19c6..ed971418 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -655,6 +655,7 @@ "presets.resize.LongEdge": "Ogranicz dłuższy bok", "presets.resize.Width": "Ustaw szerokość", "presets.resize.Custom": "Ustaw szerokość i wysokość", + "presets.resize.MinShortEdge": "Ustaw minimalny krótszy bok", "presets.rotation": "Obróć", "presets.rotation.None": "Nie obracaj", "presets.rotation.Right90": "W prawo 90°", @@ -669,6 +670,8 @@ "presets.width": "Szerokość (px)", "presets.height": "Wysokość (px)", "presets.lock_aspect": "Zachowaj proporcje", + "presets.min_short_edge": "Krótszy bok co najmniej (px)", + "presets.min_short_edge_hint": "Mniejsze obrazy są powiększane, aż ich krótszy bok osiągnie tę długość. Większe zostają bez zmian.", "presets.dpi": "DPI", "presets.dpi_keep": "Zachowaj", "presets.keep_metadata": "Zachowaj metadane (EXIF)", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index c98e43d6..f1c680e9 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -655,6 +655,7 @@ "presets.resize.LongEdge": "Limitar o lado maior", "presets.resize.Width": "Definir a largura", "presets.resize.Custom": "Definir a largura e a altura", + "presets.resize.MinShortEdge": "Definir um lado menor mínimo", "presets.rotation": "Girar", "presets.rotation.None": "Não girar", "presets.rotation.Right90": "90° à direita", @@ -669,6 +670,8 @@ "presets.width": "Largura (px)", "presets.height": "Altura (px)", "presets.lock_aspect": "Manter a proporção", + "presets.min_short_edge": "Lado menor mínimo (px)", + "presets.min_short_edge_hint": "Imagens menores são ampliadas até o lado menor ter esse tamanho. As maiores ficam como estão.", "presets.dpi": "DPI", "presets.dpi_keep": "Manter", "presets.keep_metadata": "Manter metadados (EXIF)", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 8692facc..1fb128b1 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -655,6 +655,7 @@ "presets.resize.LongEdge": "Limitar o lado maior", "presets.resize.Width": "Definir a largura", "presets.resize.Custom": "Definir a largura e a altura", + "presets.resize.MinShortEdge": "Definir um lado menor mínimo", "presets.rotation": "Rodar", "presets.rotation.None": "Não rodar", "presets.rotation.Right90": "90° à direita", @@ -669,6 +670,8 @@ "presets.width": "Largura (px)", "presets.height": "Altura (px)", "presets.lock_aspect": "Manter a proporção", + "presets.min_short_edge": "Lado menor mínimo (px)", + "presets.min_short_edge_hint": "As imagens mais pequenas são ampliadas até o lado menor ter este tamanho. As maiores ficam como estão.", "presets.dpi": "DPI", "presets.dpi_keep": "Manter", "presets.keep_metadata": "Manter metadados (EXIF)", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 7683e5f0..15e4c074 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -655,6 +655,7 @@ "presets.resize.LongEdge": "Ограничить длинную сторону", "presets.resize.Width": "Задать ширину", "presets.resize.Custom": "Задать ширину и высоту", + "presets.resize.MinShortEdge": "Задать минимум короткой стороны", "presets.rotation": "Повернуть", "presets.rotation.None": "Не поворачивать", "presets.rotation.Right90": "Вправо на 90°", @@ -669,6 +670,8 @@ "presets.width": "Ширина (px)", "presets.height": "Высота (px)", "presets.lock_aspect": "Сохранять пропорции", + "presets.min_short_edge": "Короткая сторона не меньше (px)", + "presets.min_short_edge_hint": "Изображения поменьше увеличиваются, пока короткая сторона не достигнет этой длины. Более крупные остаются как есть.", "presets.dpi": "DPI", "presets.dpi_keep": "Как есть", "presets.keep_metadata": "Сохранять метаданные (EXIF)", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 0c597319..77379e01 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -640,6 +640,7 @@ "presets.resize.LongEdge": "จำกัดความยาวด้านที่ยาวที่สุด", "presets.resize.Width": "กำหนดความกว้าง", "presets.resize.Custom": "กำหนดความกว้างและความสูง", + "presets.resize.MinShortEdge": "กำหนดความยาวขั้นต่ำของด้านที่สั้นที่สุด", "presets.rotation": "หมุนภาพ", "presets.rotation.None": "ไม่หมุน", "presets.rotation.Right90": "ขวา 90°", @@ -654,6 +655,8 @@ "presets.width": "ความกว้าง (px)", "presets.height": "ความสูง (px)", "presets.lock_aspect": "รักษาอัตราส่วนภาพ", + "presets.min_short_edge": "ด้านสั้นที่สุดอย่างน้อย (px)", + "presets.min_short_edge_hint": "รูปที่เล็กกว่าจะถูกขยายจนด้านที่สั้นที่สุดยาวเท่านี้ ส่วนรูปที่ใหญ่กว่าจะคงไว้ตามเดิม", "presets.dpi": "DPI", "presets.dpi_keep": "คงค่าเดิม", "presets.keep_metadata": "คงข้อมูลเมทาดาตา (EXIF)", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index e350fbc6..8265c4df 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -640,6 +640,7 @@ "presets.resize.LongEdge": "Uzun kenarı sınırla", "presets.resize.Width": "Genişliği ayarla", "presets.resize.Custom": "Genişliği ve yüksekliği ayarla", + "presets.resize.MinShortEdge": "Kısa kenar için alt sınır", "presets.rotation": "Döndür", "presets.rotation.None": "Döndürme", "presets.rotation.Right90": "Sağa 90°", @@ -654,6 +655,8 @@ "presets.width": "Genişlik (px)", "presets.height": "Yükseklik (px)", "presets.lock_aspect": "En boy oranını koru", + "presets.min_short_edge": "Kısa kenar en az (px)", + "presets.min_short_edge_hint": "Daha küçük resimler, kısa kenarı bu uzunluğa ulaşana kadar büyütülür. Daha büyük olanlar olduğu gibi kalır.", "presets.dpi": "DPI", "presets.dpi_keep": "Koru", "presets.keep_metadata": "Üst veriyi koru (EXIF)", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 4b96b320..b0383e0a 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -655,6 +655,7 @@ "presets.resize.LongEdge": "限制长边", "presets.resize.Width": "设置宽度", "presets.resize.Custom": "设置宽度和高度", + "presets.resize.MinShortEdge": "设置短边最小值", "presets.rotation": "旋转", "presets.rotation.None": "不旋转", "presets.rotation.Right90": "向右 90°", @@ -669,6 +670,8 @@ "presets.width": "宽度(像素)", "presets.height": "高度(像素)", "presets.lock_aspect": "保持宽高比", + "presets.min_short_edge": "短边最小值 (px)", + "presets.min_short_edge_hint": "较小的图片会放大到短边达到此长度,较大的图片保持不变。", "presets.dpi": "DPI", "presets.dpi_keep": "保持", "presets.keep_metadata": "保留元数据 (EXIF)", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 8a56d201..877363c8 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -655,6 +655,7 @@ "presets.resize.LongEdge": "限制長邊", "presets.resize.Width": "設定寬度", "presets.resize.Custom": "設定寬度和高度", + "presets.resize.MinShortEdge": "設定短邊最小值", "presets.rotation": "旋轉", "presets.rotation.None": "不旋轉", "presets.rotation.Right90": "向右 90°", @@ -669,6 +670,8 @@ "presets.width": "寬度(像素)", "presets.height": "高度(像素)", "presets.lock_aspect": "保持長寬比", + "presets.min_short_edge": "短邊最小值 (px)", + "presets.min_short_edge_hint": "較小的圖片會放大到短邊達到此長度,較大的圖片保持不變。", "presets.dpi": "DPI", "presets.dpi_keep": "保持", "presets.keep_metadata": "保留中繼資料 (EXIF)", diff --git a/src/Filee.App/ViewModels/PresetEditorViewModel.cs b/src/Filee.App/ViewModels/PresetEditorViewModel.cs index 40090a64..e2dade5e 100644 --- a/src/Filee.App/ViewModels/PresetEditorViewModel.cs +++ b/src/Filee.App/ViewModels/PresetEditorViewModel.cs @@ -116,7 +116,9 @@ .. FormatRegistry.Known _name = string.IsNullOrWhiteSpace(_edit.Name) ? loc.DisplayName(_edit) : _edit.Name; _target = Targets.FirstOrDefault(t => t.Value == _edit.TargetFormat) ?? Targets[1]; - _resize = ResizeModes.First(r => r.Value == (_edit.Image.CustomSize is null ? _edit.Image.Resize : ResizeMode.Custom)); + _resize = ResizeModes.First(r => r.Value == (_edit.Image.CustomSize is not null ? ResizeMode.Custom + : _edit.Image.ClampedMinShortEdge is not null ? ResizeMode.MinShortEdge + : _edit.Image.Resize)); _rotation = Rotations.FirstOrDefault(r => r.Value == _edit.Image.Rotation) ?? Rotations[0]; _flip = Flips.FirstOrDefault(f => f.Value == _edit.Image.Flip) ?? Flips[0]; _pageSize = PageSizes.First(p => p.Value == _edit.Pdf.PageSize); @@ -169,6 +171,7 @@ .. FormatRegistry.Known _customWidth = _edit.Image.CustomSize?.Width ?? 1920; _customHeight = _edit.Image.CustomSize?.Height ?? 1080; _lockAspect = _edit.Image.CustomSize?.LockAspect ?? false; + _minShortEdge = _edit.Image.ClampedMinShortEdge ?? 1080; _setDpi = _edit.Image.Dpi.HasValue; _dpi = _edit.Image.Dpi ?? 300; _keepMetadata = _edit.Image.KeepMetadata; @@ -375,7 +378,7 @@ public Choice AudioCodecChoice } } - [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowPercent), nameof(ShowLongEdge), nameof(ShowWidth), nameof(ShowCustom))] + [ObservableProperty, NotifyPropertyChangedFor(nameof(ShowPercent), nameof(ShowLongEdge), nameof(ShowWidth), nameof(ShowCustom), nameof(ShowMinShortEdge))] private Choice _resize; [ObservableProperty] private Choice _rotation; @@ -405,6 +408,8 @@ public Choice AudioCodecChoice [ObservableProperty] private int _customHeight; /// With Custom: keep each file's shape and fit inside, instead of stretching (ImageOptions.LockAspect). [ObservableProperty] private bool _lockAspect; + /// With MinShortEdge: the shortest the shorter edge may be (ImageOptions.MinShortEdge). + [ObservableProperty] private int _minShortEdge; [ObservableProperty] private bool _setDpi; [ObservableProperty] private int _dpi; [ObservableProperty] @@ -666,6 +671,7 @@ partial void OnAnimationSpeedChanged(Choice? oldValue, Choice ne public bool ShowLongEdge => Resize.Value == ResizeMode.LongEdge; public bool ShowWidth => Resize.Value == ResizeMode.Width; public bool ShowCustom => Resize.Value == ResizeMode.Custom; + public bool ShowMinShortEdge => Resize.Value == ResizeMode.MinShortEdge; public bool ShowMargin => PageSize.Value != PdfPageSize.FitImage; public bool ShowSubfolder => Location.Value == OutputLocation.Subfolder; public bool ShowCustomFolder => Location.Value == OutputLocation.CustomFolder; @@ -698,11 +704,20 @@ public bool Apply() // write those over the user's own; issue #576). image.Resize = ResizeMode.None; image.CustomSize = new CustomImageSize { Width = Math.Clamp(CustomWidth, CustomImageSize.MinSide, CustomImageSize.MaxSide), Height = Math.Clamp(CustomHeight, CustomImageSize.MinSide, CustomImageSize.MaxSide), LockAspect = LockAspect }; + image.MinShortEdge = null; + } + else if (Resize.Value == ResizeMode.MinShortEdge) + { + // Stored the same way, as MinShortEdge with Resize None (issue #652). + image.Resize = ResizeMode.None; + image.CustomSize = null; + image.MinShortEdge = Math.Clamp(MinShortEdge, CustomImageSize.MinSide, CustomImageSize.MaxSide); } else { image.Resize = Resize.Value; image.CustomSize = null; + image.MinShortEdge = null; } // Only images and PDFs are turned; for any other target a rotation left over from an earlier target would make // every file fail ("can only turn images") with nothing on screen to turn it off. diff --git a/src/Filee.App/Views/PresetEditorView.axaml b/src/Filee.App/Views/PresetEditorView.axaml index add84a7b..4fbfe945 100644 --- a/src/Filee.App/Views/PresetEditorView.axaml +++ b/src/Filee.App/Views/PresetEditorView.axaml @@ -142,6 +142,11 @@ + + + + diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index c5b2af3c..6513a10c 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -61,15 +61,16 @@ public sealed class PresetKindJsonConverter : LenientEnumJsonConverter -/// Reads like the lenient converter and, when writing, never emits (an editor-only -/// choice): a preset with custom sizing stores Resize None plus CustomSize, so a version without the mode keeps +/// Reads like the lenient converter and, when writing, never emits or +/// (editor-only choices): a preset with custom sizing stores Resize None plus +/// CustomSize, one with a minimum short edge Resize None plus MinShortEdge, so a version without the mode keeps /// reading presets.json instead of rejecting it as broken and overwriting the user's presets with the built-in -/// ones (issue #576). +/// ones (issues #576, #652). /// public sealed class ResizeModeJsonConverter : LenientEnumJsonConverter { public override void Write(Utf8JsonWriter writer, ResizeMode value, JsonSerializerOptions options) => - writer.WriteStringValue((value == ResizeMode.Custom ? ResizeMode.None : value).ToString()); + writer.WriteStringValue((value is ResizeMode.Custom or ResizeMode.MinShortEdge ? ResizeMode.None : value).ToString()); } /// @@ -284,6 +285,12 @@ public enum ResizeMode /// , so a version without the mode still reads the file. /// Custom, + /// + /// Scale up so the shorter edge is at least , in proportion; a picture + /// that already reaches it is left as it is (never shrinks; issue #652). An editor choice only, stored like + /// : None plus . + /// + MinShortEdge, } /// How an image is turned, after its EXIF orientation is applied. @@ -337,10 +344,10 @@ public sealed class ImageOptions public double MaxSizeMb { get; set; } /// - /// How the picture is scaled. A preset with set stores None here (custom sizing wins), - /// so a version without custom sizing simply does not resize instead of rejecting a name it does not know. The - /// lenient converter also reads a name a future version might add as None, and the writer maps Custom down to - /// None (see ). + /// How the picture is scaled. A preset with or set stores None + /// here (that sizing wins), so a version without it simply does not resize instead of rejecting a name it does + /// not know. The lenient converter also reads a name a future version might add as None, and the writer maps + /// Custom and MinShortEdge down to None (see ). /// [JsonConverter(typeof(ResizeModeJsonConverter))] public ResizeMode Resize { get; set; } = ResizeMode.None; @@ -359,7 +366,7 @@ public sealed class ImageOptions /// Whether the preset edits picture content, size, or playback speed. [JsonIgnore] - public bool EditsPicture => Resize != ResizeMode.None || Grayscale || TrimTransparent || RemoveBackground || MaxCompression || ChangesSpeed || CustomSize is not null; + public bool EditsPicture => Resize != ResizeMode.None || Grayscale || TrimTransparent || RemoveBackground || MaxCompression || ChangesSpeed || CustomSize is not null || MinShortEdge is not null; /// Scale in percent when is . public int ResizePercent { get; set; } = 50; @@ -379,6 +386,23 @@ public sealed class ImageOptions /// public CustomImageSize? CustomSize { get; set; } + /// + /// The shortest the shorter edge may be, in pixels (issue #652): a smaller picture is scaled up in proportion + /// until its shorter edge reaches this, a picture that already does is left as it is. When set it wins over + /// (only comes first); null leaves in charge. + /// A separate field for the same reason as : older versions skip it and do not resize. + /// + public int? MinShortEdge { get; set; } + + /// + /// as it is used: within the editor's range ( to + /// ), or null when it is unset or, from a hand-edited file, 0 or below. + /// + [JsonIgnore] + public int? ClampedMinShortEdge => MinShortEdge is > 0 and var edge + ? Math.Clamp(edge, CustomImageSize.MinSide, CustomImageSize.MaxSide) + : null; + /// Output DPI written into the file. null keeps the source value. public int? Dpi { get; set; } diff --git a/src/Filee.Engines/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs index 3847a1a1..d313932a 100644 --- a/src/Filee.Engines/Magick/ImageEncoder.cs +++ b/src/Filee.Engines/Magick/ImageEncoder.cs @@ -208,13 +208,32 @@ public static double ResizeScale(double width, double height, ImageOptions optio ? custom.LockAspect ? Math.Min(custom.ClampedWidth / width, custom.ClampedHeight / height) : custom.ClampedHeight / height - : options.Resize switch - { - ResizeMode.Percent when options.ResizePercent > 0 => options.ResizePercent / 100.0, - ResizeMode.LongEdge when options.LongEdge > 0 => Math.Min(1, options.LongEdge / Math.Max(width, height)), - ResizeMode.Width when options.Width > 0 => Math.Min(1, options.Width / width), - _ => 1, - }; + : options.ClampedMinShortEdge is { } minShortEdge + ? MinShortEdgeScale(width, height, minShortEdge) + : options.Resize switch + { + ResizeMode.Percent when options.ResizePercent > 0 => options.ResizePercent / 100.0, + ResizeMode.LongEdge when options.LongEdge > 0 => Math.Min(1, options.LongEdge / Math.Max(width, height)), + ResizeMode.Width when options.Width > 0 => Math.Min(1, options.Width / width), + _ => 1, + }; + + /// + /// The most pixels a minimum short edge enlarges a picture to: as many as the largest custom size holds. Without + /// it a hairline strip (16 x 4000 px, short edge to 4000) would ask for a picture of 4 billion pixels. + /// + internal const double MaxEnlargedPixels = (double)CustomImageSize.MaxSide * CustomImageSize.MaxSide; + + /// + /// The factor that brings the shorter edge of a width x height picture up to + /// (issue #652): 1 when it already reaches it (never below 1, so nothing shrinks), and at most what keeps the + /// result within . + /// + public static double MinShortEdgeScale(double width, double height, int minShortEdge) + { + var scale = minShortEdge / Math.Min(width, height); + return scale <= 1 ? 1 : Math.Max(1, Math.Min(scale, Math.Sqrt(MaxEnlargedPixels / (width * height)))); + } /// Turns and mirrors an image as say (part of ). public static void Turn(IMagickImage image, ImageOptions options) @@ -307,6 +326,16 @@ public static void ApplyOptions(IMagickImage image, ImageOptions options, image.Resize(new MagickGeometry((uint)customWidth, (uint)customHeight) { IgnoreAspectRatio = true }); } } + else if (options.ClampedMinShortEdge is { } minShortEdge) + { + // Only ever up, in proportion; a picture whose shorter edge already reaches the minimum is not touched + // (issue #652). The default filter, as for Percent above 100 % and the locked custom size, which enlarge + // the same way. Both sides come from one scale, so ignoring the aspect only stops ImageMagick from + // rounding the shorter side one pixel under the minimum while it fits the box. + var scale = MinShortEdgeScale(image.Width, image.Height, minShortEdge); + if (scale > 1) + image.Resize(new MagickGeometry((uint)Math.Max(1, Math.Round(image.Width * scale)), (uint)Math.Max(1, Math.Round(image.Height * scale))) { IgnoreAspectRatio = true }); + } else switch (options.Resize) { case ResizeMode.Percent when options.ResizePercent is > 0 and not 100: diff --git a/src/Filee.Engines/Vector/SvgRenderer.cs b/src/Filee.Engines/Vector/SvgRenderer.cs index 7cab50fd..13d8145a 100644 --- a/src/Filee.Engines/Vector/SvgRenderer.cs +++ b/src/Filee.Engines/Vector/SvgRenderer.cs @@ -175,6 +175,12 @@ public static float RasterScale(SKRect bounds, ImageOptions image, PdfOptions pd ? Math.Min(custom.ClampedWidth / bounds.Width, custom.ClampedHeight / bounds.Height) : Math.Max(custom.ClampedWidth / bounds.Width, custom.ClampedHeight / bounds.Height); } + else if (image.ClampedMinShortEdge is { } minShortEdge) + { + // The usual render size, or larger where that leaves the shorter edge under the minimum: drawn at that + // size instead of enlarging a default-size bitmap afterwards, which comes out soft (issue #652). + scale = Math.Max(scale, minShortEdge / Math.Min(bounds.Width, bounds.Height)); + } else switch (image.Resize) { case ResizeMode.LongEdge when image.LongEdge > 0: diff --git a/src/Filee.Engines/Vector/VectorConverter.cs b/src/Filee.Engines/Vector/VectorConverter.cs index 550c649b..0d759349 100644 --- a/src/Filee.Engines/Vector/VectorConverter.cs +++ b/src/Filee.Engines/Vector/VectorConverter.cs @@ -122,9 +122,11 @@ private static IReadOnlyList ToRaster(ConversionStep step, IProgress(); + if (dark) + theme.Apply(new ThemeSettings { Mode = ThemeMode.Dark }, language); + try + { + var loc = AppHost.Get(); + var preset = Filee.Core.Presets.BuiltInData.CreatePresets().Single(p => p.Id == "jpg-70"); + var vm = new PresetEditorViewModel(preset, loc); + Assert.False(vm.ShowMinShortEdge); + + var raised = new List(); + vm.PropertyChanged += (_, e) => raised.Add(e.PropertyName!); + vm.Resize = vm.ResizeModes.Single(r => r.Value == Filee.Core.Presets.ResizeMode.MinShortEdge); + Assert.True(vm.ShowMinShortEdge); + Assert.False(vm.ShowCustom); + Assert.Contains(nameof(vm.ShowMinShortEdge), raised); // the box appears without reopening the editor + Assert.Equal(1080, vm.MinShortEdge); + + var window = new PresetEditorWindow { DataContext = vm, Height = 900 }; + window.Show(); + Pump(); + var box = window.GetVisualDescendants().OfType() + .Single(n => n.IsEffectivelyVisible && Avalonia.Automation.AutomationProperties.GetName(n) == loc["presets.min_short_edge"]); + Assert.False(box.ShowButtonSpinner); + box.BringIntoView(); + Pump(); + Save(window, $"preset-min-short-edge-{language}-{(dark ? "dark" : "light")}.png"); + window.Close(); + + Assert.True(vm.Apply()); + // Stored as minShortEdge with resize None, so Filee 2.1.0 keeps reading presets.json (#652). + Assert.Equal(Filee.Core.Presets.ResizeMode.None, preset.Image.Resize); + Assert.Equal(1080, preset.Image.MinShortEdge); + } + finally + { + if (dark) + theme.Apply(new ThemeSettings(), language); + } + } + + [AvaloniaFact] + public void Min_short_edge_is_stored_clamped_and_switching_the_mode_clears_it() + { + TestServices.EnsureInitialized("en"); + var loc = AppHost.Get(); + var preset = Filee.Core.Presets.BuiltInData.CreatePresets().Single(p => p.Id == "jpg-70"); + var vm = new PresetEditorViewModel(preset, loc); + vm.Resize = vm.ResizeModes.Single(r => r.Value == Filee.Core.Presets.ResizeMode.MinShortEdge); + vm.MinShortEdge = 50000; + Assert.True(vm.Apply()); + Assert.Equal(16384, preset.Image.MinShortEdge); + Assert.Null(preset.Image.CustomSize); + + vm.MinShortEdge = 720; + Assert.True(vm.Apply()); + var reloaded = new PresetEditorViewModel(preset, loc); + Assert.Equal(Filee.Core.Presets.ResizeMode.MinShortEdge, reloaded.Resize.Value); + Assert.Equal(720, reloaded.MinShortEdge); + + // Custom replaces it rather than leaving both behind... + vm.Resize = vm.ResizeModes.Single(r => r.Value == Filee.Core.Presets.ResizeMode.Custom); + Assert.True(vm.Apply()); + Assert.Null(preset.Image.MinShortEdge); + Assert.NotNull(preset.Image.CustomSize); + + // ...and so does any other mode. + vm.Resize = vm.ResizeModes.Single(r => r.Value == Filee.Core.Presets.ResizeMode.MinShortEdge); + Assert.True(vm.Apply()); + Assert.Null(preset.Image.CustomSize); + vm.Resize = vm.ResizeModes.Single(r => r.Value == Filee.Core.Presets.ResizeMode.Percent); + Assert.True(vm.Apply()); + Assert.Null(preset.Image.MinShortEdge); + Assert.Equal(Filee.Core.Presets.ResizeMode.Percent, preset.Image.Resize); + + // A minimum of 0 or below from a hand-edited file does nothing, so the editor shows the preset's own resize. + preset.Image.MinShortEdge = -5; + Assert.Equal(Filee.Core.Presets.ResizeMode.Percent, new PresetEditorViewModel(preset, loc).Resize.Value); + } + [AvaloniaTheory] [InlineData("en", false)] [InlineData("ru", false)] diff --git a/tests/Filee.Core.Tests/UserDataStoreTests.cs b/tests/Filee.Core.Tests/UserDataStoreTests.cs index fb53fbd2..d076934b 100644 --- a/tests/Filee.Core.Tests/UserDataStoreTests.cs +++ b/tests/Filee.Core.Tests/UserDataStoreTests.cs @@ -105,6 +105,53 @@ public void A_resize_mode_of_Custom_is_never_written_to_presets_json() } } + [Fact] + public void A_preset_with_a_minimum_short_edge_never_writes_a_resize_name_an_older_version_rejects() + { + using var dir = new TempDir(); + var store = new UserDataStore(dir.Path); + store.Load(); + var preset = store.Presets[0]; + preset.Image.Resize = ResizeMode.None; + preset.Image.MinShortEdge = 1080; + store.SaveLibrary(); + + // What Filee 2.1.0 reads: every resize stays a name it knows, and the unknown minShortEdge is skipped (#652). + var stored = JsonDocument.Parse(File.ReadAllText(Path.Combine(dir.Path, "presets.json"))); + foreach (var p in stored.RootElement.EnumerateArray()) + { + var name = p.GetProperty("image").GetProperty("resize").GetString(); + Assert.Contains(name, new[] { "None", "Percent", "LongEdge", "Width" }); + } + var image = stored.RootElement.EnumerateArray().Single(p => p.GetProperty("id").GetString() == preset.Id).GetProperty("image"); + Assert.Equal(1080, image.GetProperty("minShortEdge").GetInt32()); + // Presets without it keep the shape they had: no "minShortEdge": null written into each one. + Assert.All(stored.RootElement.EnumerateArray().Where(p => p.GetProperty("id").GetString() != preset.Id), + p => Assert.False(p.GetProperty("image").TryGetProperty("minShortEdge", out _))); + + // And this version reads it back. + var reloaded = new UserDataStore(dir.Path); + reloaded.Load(); + var back = reloaded.Presets.Single(p => p.Id == preset.Id); + Assert.Equal(ResizeMode.None, back.Image.Resize); + Assert.Equal(1080, back.Image.MinShortEdge); + Assert.True(back.Image.EditsPicture); + Assert.Equal(1080, back.Image.Clone().MinShortEdge); + } + + [Fact] + public void A_resize_mode_of_MinShortEdge_is_never_written_to_presets_json() + { + using var dir = new TempDir(); + var store = new UserDataStore(dir.Path); + store.Load(); + store.Presets[0].Image.Resize = ResizeMode.MinShortEdge; + store.SaveLibrary(); + + var stored = JsonDocument.Parse(File.ReadAllText(Path.Combine(dir.Path, "presets.json"))); + Assert.Equal("None", stored.RootElement[0].GetProperty("image").GetProperty("resize").GetString()); + } + [Fact] public void A_resize_name_this_version_does_not_know_reads_as_no_resize() { diff --git a/tests/Filee.Engines.Tests/GraphicsTests.cs b/tests/Filee.Engines.Tests/GraphicsTests.cs index ed739b79..32bc1747 100644 --- a/tests/Filee.Engines.Tests/GraphicsTests.cs +++ b/tests/Filee.Engines.Tests/GraphicsTests.cs @@ -477,6 +477,33 @@ public void Custom_resize_renders_vectors_at_the_asked_scale_instead_of_stretchi SvgRenderer.RasterScale(bounds, zeroed, new PdfOptions())); } + [Fact] + public async Task Min_short_edge_renders_a_vector_picture_at_least_that_large() + { + var dir = fx.NewFolder(); + var svg = Write(dir, "logo.svg", GraphicsBuilders.Svg); + + var rendered = await fx.ConvertAsync([svg], new Preset { TargetFormat = "png", Image = { MinShortEdge = 1080 } }); + + AssertDone(rendered); + using var png = new MagickImage(rendered.Outputs.Single()); + // The 400 x 200 drawing is drawn at 1080 / 200 = 5.4, not enlarged as a bitmap afterwards. + Assert.Equal((2160u, 1080u), (png.Width, png.Height)); + } + + [Fact] + public void Min_short_edge_renders_vectors_larger_only_where_the_usual_size_falls_short() + { + var bounds = new SKRect(0, 0, 400, 200); + var usual = SvgRenderer.RasterScale(bounds, new ImageOptions(), new PdfOptions()); + + Assert.Equal(5.4f, SvgRenderer.RasterScale(bounds, new ImageOptions { MinShortEdge = 1080 }, new PdfOptions()), 3); + // The usual render (1024 x 512 px here) already has a short edge of 300 or more: left at that size. + Assert.Equal(usual, SvgRenderer.RasterScale(bounds, new ImageOptions { MinShortEdge = 300 }, new PdfOptions())); + // A minimum of 0 or below from a hand-edited file is no minimum. + Assert.Equal(usual, SvgRenderer.RasterScale(bounds, new ImageOptions { MinShortEdge = 0 }, new PdfOptions())); + } + [Fact] public async Task Files_that_are_not_svg_fail_with_a_message() { diff --git a/tests/Filee.Engines.Tests/ImageAndPdfTests.cs b/tests/Filee.Engines.Tests/ImageAndPdfTests.cs index 4d12b647..09d8f4a8 100644 --- a/tests/Filee.Engines.Tests/ImageAndPdfTests.cs +++ b/tests/Filee.Engines.Tests/ImageAndPdfTests.cs @@ -458,6 +458,115 @@ public void Custom_resize_plans_pdf_page_runs_at_the_scale_the_resize_will_use() Assert.Equal(1, Filee.Engines.Magick.ImageEncoder.ResizeScale(400, 800, negative), 3); } + [Fact] + public void Min_short_edge_enlarges_a_small_picture_in_proportion() + { + // The case from the issue (#652): a site wants at least 1080 px on the short side. + var options = new ImageOptions { MinShortEdge = 1080 }; + + using var landscape = new MagickImage(MagickColors.White, 400, 300); + Filee.Engines.Magick.ImageEncoder.ApplyOptions(landscape, options, "png"); + Assert.Equal((1440u, 1080u), (landscape.Width, landscape.Height)); + + using var portrait = new MagickImage(MagickColors.White, 300, 500); + Filee.Engines.Magick.ImageEncoder.ApplyOptions(portrait, options, "png"); + Assert.Equal((1080u, 1800u), (portrait.Width, portrait.Height)); + + // An uneven factor still lands the short edge exactly on the minimum, never a pixel under it. + using var uneven = new MagickImage(MagickColors.White, 333, 500); + Filee.Engines.Magick.ImageEncoder.ApplyOptions(uneven, new ImageOptions { MinShortEdge = 1000 }, "png"); + Assert.Equal((1000u, 1502u), (uneven.Width, uneven.Height)); + } + + [Fact] + public void Min_short_edge_never_shrinks_a_picture_that_already_reaches_it() + { + var options = new ImageOptions { MinShortEdge = 1080 }; + + using var large = new MagickImage(MagickColors.White, 4000, 3000); + Filee.Engines.Magick.ImageEncoder.ApplyOptions(large, options, "png"); + Assert.Equal((4000u, 3000u), (large.Width, large.Height)); + + using var exact = new MagickImage(MagickColors.White, 1080, 1920); + Filee.Engines.Magick.ImageEncoder.ApplyOptions(exact, options, "png"); + Assert.Equal((1080u, 1920u), (exact.Width, exact.Height)); + + Assert.Equal(1, Filee.Engines.Magick.ImageEncoder.ResizeScale(4000, 3000, options), 3); + } + + [Fact] + public void A_min_short_edge_read_from_a_file_is_skipped_at_zero_or_below_and_kept_in_the_editors_range() + { + // The editor writes 16-16384; a hand-edited presets.json or an imported bundle can carry anything. + foreach (var edge in new[] { 0, -5 }) + { + using var image = new MagickImage(MagickColors.White, 250, 444); + Filee.Engines.Magick.ImageEncoder.ApplyOptions(image, new ImageOptions { MinShortEdge = edge }, "png"); + Assert.Equal((250u, 444u), (image.Width, image.Height)); + Assert.Null(new ImageOptions { MinShortEdge = edge }.ClampedMinShortEdge); + } + + // Under 16 comes up to it... + using var tiny = new MagickImage(MagickColors.White, 8, 10); + Filee.Engines.Magick.ImageEncoder.ApplyOptions(tiny, new ImageOptions { MinShortEdge = 5 }, "png"); + Assert.Equal((16u, 20u), (tiny.Width, tiny.Height)); + + // ...and a hand-edited 200000 stops at 16384 (planned here: the picture itself would be 268 megapixels). + Assert.Equal(16384, new ImageOptions { MinShortEdge = 200000 }.ClampedMinShortEdge); + Assert.Equal(40.96, Filee.Engines.Magick.ImageEncoder.ResizeScale(400, 400, new ImageOptions { MinShortEdge = 200000 }), 3); + } + + [Fact] + public void Min_short_edge_does_not_enlarge_a_hairline_strip_past_the_largest_custom_size() + { + // 16 x 4000 to a short edge of 4000 would be 4000 x 1,000,000 px: it stops at as many pixels as the largest + // custom size (16384 x 16384) holds. + var scale = Filee.Engines.Magick.ImageEncoder.MinShortEdgeScale(16, 4000, 4000); + Assert.Equal(16384.0 * 16384, 16 * scale * 4000 * scale, 0); + Assert.True(scale > 1); + } + + [Fact] + public void Min_short_edge_still_fits_the_format_after_enlarging() + { + // WebP holds 16,383 px a side: a tall 100 x 2000 strip enlarged to a 1080 short edge (21,600 px tall) is + // brought back inside it, as after any other resize. + using var strip = new MagickImage(MagickColors.White, 100, 2000); + Filee.Engines.Magick.ImageEncoder.ApplyOptions(strip, new ImageOptions { MinShortEdge = 1080 }, "webp"); + Assert.InRange(strip.Height, 16300u, 16383u); + Assert.Equal(20.0, strip.Height / (double)strip.Width, 1); + } + + [Fact] + public async Task Min_short_edge_enlarges_in_a_conversion_and_an_icon_keeps_its_256_px_cap() + { + var dir = fx.NewFolder(); + var source = EngineFixture.MakeImage(dir, "small.png", MagickFormat.Png, 300, 200); + + var jpg = await fx.ConvertAsync([source], new Preset { TargetFormat = "jpg", Image = { MinShortEdge = 1080 } }); + AssertDone(jpg); + using (var picture = new MagickImage(jpg.Outputs.Single())) + Assert.Equal((1620u, 1080u), (picture.Width, picture.Height)); + + var ico = await fx.ConvertAsync([source], new Preset { TargetFormat = "ico", Image = { MinShortEdge = 1080 } }); + AssertDone(ico); + using var icons = new MagickImageCollection(ico.Outputs.Single()); + Assert.Equal(256u, icons.Max(i => i.Width)); + } + + [Fact] + public void Min_short_edge_plans_pdf_page_runs_at_the_scale_the_resize_will_use() + { + // How PdfiumConverter.Split plans runs: the factor the stacked strip is scaled by. + Assert.Equal(2.5, Filee.Engines.Magick.ImageEncoder.ResizeScale(400, 800, new ImageOptions { MinShortEdge = 1000 }), 3); + Assert.Equal(1, Filee.Engines.Magick.ImageEncoder.ResizeScale(400, 800, new ImageOptions { MinShortEdge = 300 }), 3); + Assert.Equal(1, Filee.Engines.Magick.ImageEncoder.ResizeScale(400, 800, new ImageOptions { MinShortEdge = 0 }), 3); + + // Custom sizing comes first when a hand-edited file carries both. + var both = new ImageOptions { MinShortEdge = 1000, CustomSize = new() { Width = 200, Height = 400 } }; + Assert.Equal(0.5, Filee.Engines.Magick.ImageEncoder.ResizeScale(400, 800, both), 3); + } + [Fact] public void A_long_image_splits_between_pages_into_parts_the_format_holds() { diff --git a/tests/Filee.Engines.Tests/SizeLimitTests.cs b/tests/Filee.Engines.Tests/SizeLimitTests.cs index 43ce69ef..181b57e1 100644 --- a/tests/Filee.Engines.Tests/SizeLimitTests.cs +++ b/tests/Filee.Engines.Tests/SizeLimitTests.cs @@ -84,6 +84,24 @@ public void A_picture_is_never_made_a_thumbnail_to_fit() Assert.Equal((uint)ImageEncoder.MinFittedLongEdge, Math.Max(last.Width, last.Height)); } + [Fact] + public async Task The_limit_still_wins_after_a_minimum_short_edge_enlarged_the_picture() + { + // As after any other resize (#652): the enlarged picture is made smaller again when it breaks the limit. + var dir = fx.NewFolder(); + var source = Path.Combine(dir, "noise.png"); + using (var image = Noisy(400, 300)) + image.Write(source, MagickFormat.Png); + + var job = await fx.ConvertAsync([source], new Preset { TargetFormat = "jpg", Image = { MinShortEdge = 1080, MaxSizeMb = 0.1 } }); + + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey} {f.ErrorDetail}"))); + var output = job.Outputs.Single(); + Assert.InRange(new FileInfo(output).Length, 1, SizeLimit.Bytes(0.1)); + using var fitted = new MagickImage(output); + Assert.InRange(Math.Max(fitted.Width, fitted.Height), (uint)ImageEncoder.MinFittedLongEdge, 1439u); + } + [Fact] public void Megabytes_are_a_million_bytes_as_the_services_count_them() {