From d99bef2b9a640d4460a287ab9a6e3558042eb8ca Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 28 Oct 2026 02:42:55 +0900 Subject: [PATCH 1/3] feat(images): Merge images, pictures one under another or side by side (#645) A new preset on the back of the images donut, before Merge PDF (in place of GIF, which stays in the library). Dropping several pictures on it opens the merge order window, which for pictures also offers the layout: top to bottom or side by side, the same width / height or their own sizes centred, a gap in pixels and a background colour or transparency. The choices are remembered in the preset. The ImageMagick engine merges them (IImageJoiner) into one image named after the first picture ({name}_merged), in its format (PNG when Filee can't write it). Pictures ImageMagick doesn't read become PNG first. A merged image over 200 megapixels, the size of the longest Long image, is refused with its size. Settings schema 53 adds the preset to existing libraries and the images donut's back face. The new layout fields are separate fields with lenient enum converters, so older presets files and Filee 2.1.0 stay readable. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 4 + src/Filee.App/Assets/i18n/ar.json | 12 + src/Filee.App/Assets/i18n/de.json | 12 + src/Filee.App/Assets/i18n/en.json | 12 + src/Filee.App/Assets/i18n/es.json | 12 + src/Filee.App/Assets/i18n/fr.json | 12 + src/Filee.App/Assets/i18n/hi.json | 12 + src/Filee.App/Assets/i18n/ja.json | 12 + src/Filee.App/Assets/i18n/ko.json | 12 + src/Filee.App/Assets/i18n/ne.json | 12 + src/Filee.App/Assets/i18n/pl.json | 12 + src/Filee.App/Assets/i18n/pt-BR.json | 12 + src/Filee.App/Assets/i18n/pt-PT.json | 12 + src/Filee.App/Assets/i18n/ru.json | 12 + src/Filee.App/Assets/i18n/th.json | 12 + src/Filee.App/Assets/i18n/tr.json | 12 + src/Filee.App/Assets/i18n/zh-CN.json | 12 + src/Filee.App/Assets/i18n/zh-TW.json | 12 + src/Filee.App/Services/PresetAvailability.cs | 17 ++ src/Filee.App/Services/RadialController.cs | 36 ++- src/Filee.App/Services/WindowService.cs | 21 +- .../ViewModels/MergeOrderViewModel.cs | 84 +++++- src/Filee.App/Views/MergeOrderWindow.axaml | 96 +++++- src/Filee.Core/Conversion/IConverter.cs | 24 ++ src/Filee.Core/Conversion/JobQueue.cs | 125 ++++++-- src/Filee.Core/Presets/AskedLocation.cs | 5 +- src/Filee.Core/Presets/BuiltInData.cs | 22 +- src/Filee.Core/Presets/Preset.cs | 61 ++++ src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 40 +++ src/Filee.Engines/Magick/ImageJoiner.cs | 175 +++++++++++ .../Magick/MagickImageConverter.cs | 11 +- tests/Filee.App.Tests/MergeOrderTests.cs | 237 ++++++++++++++- tests/Filee.Core.Tests/MergeImagesTests.cs | 277 ++++++++++++++++++ .../MergeImagesEngineTests.cs | 229 +++++++++++++++ 35 files changed, 1623 insertions(+), 47 deletions(-) create mode 100644 src/Filee.Engines/Magick/ImageJoiner.cs create mode 100644 tests/Filee.Core.Tests/MergeImagesTests.cs create mode 100644 tests/Filee.Engines.Tests/MergeImagesEngineTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 736a8dfd1..17464aea7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -65,6 +65,10 @@ window, into one file with FFmpeg (`IMediaJoiner`); pictures become PNG through the image engines first. Like the other one-file jobs, the output follows "subfolder only for several files", and a join that fails or is cancelled is undone by the job's allocator, which deletes only paths that were the job's own. + "Merge images" puts the pictures, in the order picked in the same window (with their layout: one under another or + side by side, the same width or their own sizes, a gap and a background), into one image with ImageMagick + (`IImageJoiner`), in the first picture's format. Pictures ImageMagick doesn't read become PNG first; a merged image + over 200 megapixels is refused with its size. 6. **Report** – progress and results appear in the toast window and in Home → Recent conversions. Renderers check only the original source for referenced fonts. `ConversionStep.MissingFonts` reports any missing families to `FileResult.MissingFonts`; the toast card can offer one trusted source, while the CLI and watch jobs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 8738fcda1..adb3cb293 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -91,6 +91,7 @@ "error.ocr_needs_tesseract": "لا يستطيع Apple Vision قراءة هذه اللغة. ثبّت Tesseract من الإعدادات ← المحركات", "error.too_many_frames": "عدد الإطارات كبير جدًا لحفظها كصور. زِد قيمة «كل إطار رقم N»", "error.too_many_pictures": "عدد الصور كبير جدًا لفيديو واحد، قسّمها على عدة فيديوهات", + "error.merged_image_too_large": "ستكون الصورة المدمجة كبيرة جدًا، ادمج صورًا أقل أو أصغر", "error.over_size_limit": "لا يمكن تصغيره بما يكفي ليناسب حد الحجم", "error.not_enough_space": "لا توجد مساحة كافية على القرص لحفظ الصور", "error.no_motion_photo": "لا تحتوي هذه الصورة على فيديو يستطيع Filee قراءته (صورة Motion Photo من Android، أو Live Photo من iPhone مع ملف MOV بجانبها)", @@ -331,6 +332,16 @@ "preset.merge_pdf": "دمج PDF", "merge.order_title": "اختيار ترتيب الدمج", "merge.order_hint": "حدّد ملفًا وانقله إلى أعلى أو أسفل. تُدمج الملفات بالترتيب من الأعلى إلى الأسفل. (‎Alt+↑ / Alt+↓‎)", + "merge.images_hint": "حدّد صورة وانقلها إلى أعلى أو أسفل. تأتي الأولى في الأعلى، أو على اليسار عند وضع الصور جنبًا إلى جنب. (‎Alt+↑ / Alt+↓‎)", + "merge.vertical": "من الأعلى إلى الأسفل", + "merge.horizontal": "جنبًا إلى جنب", + "merge.same_width": "العرض نفسه", + "merge.same_height": "الارتفاع نفسه", + "merge.same_size_tip": "يغيّر حجم الصور لتتطابق حوافها. عند الإيقاف تحتفظ كل صورة بحجمها في المنتصف.", + "merge.gap": "المسافة (بكسل)", + "merge.background": "الخلفية", + "merge.transparent": "شفافة", + "merge.transparent_tip": "لصيغ PNG وWEBP وغيرها مما يحفظ الشفافية. تصبح هذه المساحة بيضاء في JPG.", "preset.group_pdf_edit": "تحرير", "preset.pdf_crop": "اقتصاص PDF", "preset.pdf_crop.short": "اقتصاص", @@ -378,6 +389,7 @@ "preset.merge_videos": "دمج الفيديوهات", "preset.compress_audio": "ضغط الصوت", "preset.merge_audio": "دمج الصوت", + "preset.merge_images": "دمج الصور", "preset.remove_metadata": "إزالة البيانات الوصفية", "format.folder": "مجلد", "format.category.Image": "صورة", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 154a25121..32a9a3f24 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -91,6 +91,7 @@ "error.ocr_needs_tesseract": "Apple Vision kann diese Sprache nicht lesen. Installiere Tesseract unter Einstellungen → Module", "error.too_many_frames": "Zu viele Einzelbilder zum Speichern. Erhöhe den Wert für „Jedes n-te Einzelbild“", "error.too_many_pictures": "Zu viele Bilder für ein Video. Teile sie auf mehrere Videos auf", + "error.merged_image_too_large": "Das zusammengeführte Bild wäre zu groß. Führe weniger oder kleinere Bilder zusammen", "error.over_size_limit": "Es lässt sich nicht klein genug für das Größenlimit machen", "error.not_enough_space": "Nicht genügend freier Speicherplatz für die Bilder", "error.no_motion_photo": "Dieses Foto enthält kein für Filee lesbares Video (Android Motion Photo oder iPhone Live Photo mit zugehöriger MOV-Datei im selben Ordner)", @@ -331,6 +332,16 @@ "preset.merge_pdf": "PDF zusammenführen", "merge.order_title": "Reihenfolge festlegen", "merge.order_hint": "Wähle eine Datei und verschiebe sie nach oben oder unten. Dateien werden von oben nach unten zusammengeführt. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Wähle ein Bild und verschiebe es nach oben oder unten. Das erste kommt nach oben, nebeneinander nach links. (Alt+↑ / Alt+↓)", + "merge.vertical": "Untereinander", + "merge.horizontal": "Nebeneinander", + "merge.same_width": "Gleiche Breite", + "merge.same_height": "Gleiche Höhe", + "merge.same_size_tip": "Skaliert die Bilder, sodass ihre Kanten bündig sind. Aus: Jedes behält seine Größe und wird zentriert.", + "merge.gap": "Abstand (px)", + "merge.background": "Hintergrund", + "merge.transparent": "Transparent", + "merge.transparent_tip": "Für PNG, WEBP und andere Formate mit Transparenz. Ein JPG wird dort weiß.", "preset.group_pdf_edit": "Bearbeiten", "preset.pdf_crop": "PDF zuschneiden", "preset.pdf_crop.short": "Zuschneiden", @@ -378,6 +389,7 @@ "preset.merge_videos": "Videos zusammenführen", "preset.compress_audio": "Audio komprimieren", "preset.merge_audio": "Audio zusammenführen", + "preset.merge_images": "Bilder zusammenführen", "preset.remove_metadata": "Metadaten entfernen", "format.folder": "Ordner", "format.category.Image": "Bild", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 4cd5e0eb1..534050b1b 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -97,6 +97,7 @@ "error.ocr_needs_tesseract": "Apple Vision can't read this language. Install Tesseract in Settings → Engines", "error.too_many_frames": "Too many frames to save as pictures, raise \"Every Nth frame\"", "error.too_many_pictures": "Too many pictures for one video, split them into several", + "error.merged_image_too_large": "The merged picture would be too large, merge fewer or smaller pictures", "error.over_size_limit": "It can't be made small enough for the size limit", "error.not_enough_space": "Not enough free disk space for the pictures", "error.no_motion_photo": "This photo has no video Filee can read (an Android Motion Photo, or an iPhone Live Photo with its MOV next to it)", @@ -341,6 +342,16 @@ "preset.merge_pdf": "Merge PDF", "merge.order_title": "Choose merge order", "merge.order_hint": "Select a file and move it up or down. Files are merged from top to bottom. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Select a picture and move it up or down. The first one goes at the top, or on the left side by side. (Alt+↑ / Alt+↓)", + "merge.vertical": "Top to bottom", + "merge.horizontal": "Side by side", + "merge.same_width": "Same width", + "merge.same_height": "Same height", + "merge.same_size_tip": "Scales the pictures so their edges line up. Off: each keeps its own size, centred.", + "merge.gap": "Gap (px)", + "merge.background": "Background", + "merge.transparent": "Transparent", + "merge.transparent_tip": "For PNG, WEBP and other formats that keep transparency. A JPG gets white there.", "preset.group_pdf_edit": "Edit", "preset.pdf_crop": "Crop PDF", "preset.pdf_crop.short": "Crop", @@ -388,6 +399,7 @@ "preset.merge_videos": "Merge videos", "preset.compress_audio": "Compress audio", "preset.merge_audio": "Merge audio", + "preset.merge_images": "Merge images", "preset.remove_metadata": "Remove metadata", "format.folder": "Folder", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 02dab483e..ab4adb927 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -91,6 +91,7 @@ "error.ocr_needs_tesseract": "Apple Vision no puede leer este idioma. Instala Tesseract en Ajustes → Motores", "error.too_many_frames": "Demasiados fotogramas para guardar como imágenes; aumenta «Cada N fotogramas»", "error.too_many_pictures": "Demasiadas imágenes para un vídeo; divídelas en varios", + "error.merged_image_too_large": "La imagen unida sería demasiado grande; une menos imágenes o imágenes más pequeñas", "error.over_size_limit": "No se puede reducir lo suficiente para el límite de tamaño", "error.not_enough_space": "Espacio libre en disco insuficiente para las imágenes", "error.no_motion_photo": "Esta foto no tiene un vídeo que Filee pueda leer (una Motion Photo de Android o una Live Photo de iPhone con su MOV al lado)", @@ -331,6 +332,16 @@ "preset.merge_pdf": "Combinar PDF", "merge.order_title": "Elegir el orden de combinación", "merge.order_hint": "Selecciona un archivo y muévelo arriba o abajo. Los archivos se combinan de arriba abajo. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Selecciona una imagen y muévela arriba o abajo. La primera va arriba, o a la izquierda si van lado a lado. (Alt+↑ / Alt+↓)", + "merge.vertical": "De arriba abajo", + "merge.horizontal": "Lado a lado", + "merge.same_width": "Mismo ancho", + "merge.same_height": "Misma altura", + "merge.same_size_tip": "Escala las imágenes para que sus bordes coincidan. Desactivado: cada una conserva su tamaño, centrada.", + "merge.gap": "Separación (px)", + "merge.background": "Fondo", + "merge.transparent": "Transparente", + "merge.transparent_tip": "Para PNG, WEBP y otros formatos con transparencia. Un JPG queda en blanco ahí.", "preset.group_pdf_edit": "Editar", "preset.pdf_crop": "Recortar PDF", "preset.pdf_crop.short": "Recortar", @@ -378,6 +389,7 @@ "preset.merge_videos": "Unir vídeos", "preset.compress_audio": "Comprimir audio", "preset.merge_audio": "Unir audio", + "preset.merge_images": "Unir imágenes", "preset.remove_metadata": "Quitar metadatos", "format.folder": "Carpeta", "format.category.Image": "Imagen", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 36e31d0fa..c9c239775 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -97,6 +97,7 @@ "error.ocr_needs_tesseract": "Apple Vision ne lit pas cette langue. Installez Tesseract dans Paramètres → Moteurs", "error.too_many_frames": "Trop d'images à enregistrer, augmentez « Une image sur N »", "error.too_many_pictures": "Trop d'images pour une seule vidéo, répartissez-les en plusieurs", + "error.merged_image_too_large": "L'image fusionnée serait trop grande, fusionnez moins d'images ou des images plus petites", "error.over_size_limit": "Impossible de le rendre assez léger pour la taille maximale", "error.not_enough_space": "Pas assez d'espace disque libre pour les images", "error.no_motion_photo": "Cette photo ne contient aucune vidéo lisible par Filee (une Motion Photo Android, ou une Live Photo d'iPhone avec son MOV à côté)", @@ -341,6 +342,16 @@ "preset.merge_pdf": "Fusionner les PDF", "merge.order_title": "Choisir l'ordre de fusion", "merge.order_hint": "Sélectionnez un fichier et déplacez-le vers le haut ou le bas. Les fichiers sont fusionnés de haut en bas. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Sélectionnez une image et déplacez-la vers le haut ou le bas. La première va en haut, ou à gauche côte à côte. (Alt+↑ / Alt+↓)", + "merge.vertical": "De haut en bas", + "merge.horizontal": "Côte à côte", + "merge.same_width": "Même largeur", + "merge.same_height": "Même hauteur", + "merge.same_size_tip": "Met les images à l'échelle pour aligner leurs bords. Désactivé : chacune garde sa taille, centrée.", + "merge.gap": "Espacement (px)", + "merge.background": "Arrière-plan", + "merge.transparent": "Transparent", + "merge.transparent_tip": "Pour PNG, WEBP et les autres formats qui gardent la transparence. Un JPG y devient blanc.", "preset.group_pdf_edit": "Modifier", "preset.pdf_crop": "Rogner le PDF", "preset.pdf_crop.short": "Rogner", @@ -388,6 +399,7 @@ "preset.merge_videos": "Fusionner les vidéos", "preset.compress_audio": "Compresser l'audio", "preset.merge_audio": "Fusionner l'audio", + "preset.merge_images": "Fusionner les images", "preset.remove_metadata": "Supprimer les métadonnées", "format.folder": "Dossier", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 11dad2416..250f366fe 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -91,6 +91,7 @@ "error.ocr_needs_tesseract": "Apple Vision यह भाषा नहीं पढ़ सकता। सेटिंग्स → इंजन में Tesseract इंस्टॉल करें", "error.too_many_frames": "तस्वीरों के रूप में सहेजने के लिए बहुत ज़्यादा फ़्रेम हैं, \"हर Nवाँ फ़्रेम\" बढ़ाएँ", "error.too_many_pictures": "एक वीडियो के लिए तस्वीरें बहुत ज़्यादा हैं, इन्हें कई हिस्सों में बाँटें", + "error.merged_image_too_large": "जुड़ी हुई तस्वीर बहुत बड़ी होगी, कम या छोटी तस्वीरें जोड़ें", "error.over_size_limit": "इसे आकार सीमा जितना छोटा नहीं किया जा सकता", "error.not_enough_space": "तस्वीरों के लिए डिस्क पर पर्याप्त खाली जगह नहीं है", "error.no_motion_photo": "इस फ़ोटो में ऐसा कोई वीडियो नहीं है जिसे Filee पढ़ सके (Android मोशन फ़ोटो, या पास में अपनी MOV फ़ाइल वाली iPhone लाइव फ़ोटो)", @@ -331,6 +332,16 @@ "preset.merge_pdf": "PDF मर्ज करें", "merge.order_title": "मर्ज करने का क्रम चुनें", "merge.order_hint": "फ़ाइल चुनकर उसे ऊपर या नीचे ले जाएँ। फ़ाइलें ऊपर से नीचे के क्रम में मर्ज होंगी। (Alt+↑ / Alt+↓)", + "merge.images_hint": "तस्वीर चुनकर उसे ऊपर या नीचे ले जाएँ। पहली तस्वीर सबसे ऊपर आएगी, या साथ-साथ जोड़ने पर सबसे बाईं ओर। (Alt+↑ / Alt+↓)", + "merge.vertical": "ऊपर से नीचे", + "merge.horizontal": "साथ-साथ", + "merge.same_width": "एक जैसी चौड़ाई", + "merge.same_height": "एक जैसी ऊँचाई", + "merge.same_size_tip": "तस्वीरों का आकार बदलता है ताकि उनके किनारे मिल जाएँ। बंद होने पर हर तस्वीर अपने आकार में बीच में रहती है।", + "merge.gap": "अंतर (px)", + "merge.background": "बैकग्राउंड", + "merge.transparent": "पारदर्शी", + "merge.transparent_tip": "PNG, WEBP और पारदर्शिता रखने वाले दूसरे फ़ॉर्मैट के लिए। JPG में वह जगह सफ़ेद हो जाती है।", "preset.group_pdf_edit": "संपादित करें", "preset.pdf_crop": "PDF क्रॉप करें", "preset.pdf_crop.short": "क्रॉप", @@ -378,6 +389,7 @@ "preset.merge_videos": "वीडियो जोड़ें", "preset.compress_audio": "ऑडियो कंप्रेस करें", "preset.merge_audio": "ऑडियो जोड़ें", + "preset.merge_images": "तस्वीरें जोड़ें", "preset.remove_metadata": "मेटाडेटा हटाएँ", "format.folder": "फ़ोल्डर", "format.category.Image": "इमेज", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 5298e5dfa..9c7ecd6cf 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -91,6 +91,7 @@ "error.ocr_needs_tesseract": "Apple Vision はこの言語を読めません。「設定」→「変換エンジン」で Tesseract をインストールしてください", "error.too_many_frames": "画像として保存するフレーム数が多すぎます。「Nフレームごと」の値を大きくしてください", "error.too_many_pictures": "1本の動画にするには画像が多すぎます。いくつかに分けてください", + "error.merged_image_too_large": "結合した画像が大きすぎます。枚数を減らすか、小さい画像で結合してください", "error.over_size_limit": "サイズ上限に収まるまで小さくできません", "error.not_enough_space": "画像を保存するための空き容量が不足しています", "error.no_motion_photo": "この写真には、Fileeが読み取れる動画がありません(Androidのモーションフォト、または隣にMOVがあるiPhoneのLive Photo)", @@ -331,6 +332,16 @@ "preset.merge_pdf": "結合", "merge.order_title": "結合順序を選択", "merge.order_hint": "ファイルを選択して上下に移動します。上から順に結合されます。(Alt+↑ / Alt+↓)", + "merge.images_hint": "画像を選択して上下に移動します。最初の画像が一番上(横に並べるときは一番左)になります。(Alt+↑ / Alt+↓)", + "merge.vertical": "縦に並べる", + "merge.horizontal": "横に並べる", + "merge.same_width": "幅をそろえる", + "merge.same_height": "高さをそろえる", + "merge.same_size_tip": "端がそろうように画像を拡大・縮小します。オフにすると元のサイズのまま中央に配置します。", + "merge.gap": "間隔 (px)", + "merge.background": "背景", + "merge.transparent": "透明", + "merge.transparent_tip": "PNG、WEBP など透明を保てる形式向けです。JPG ではその部分が白になります。", "preset.group_pdf_edit": "編集", "preset.pdf_crop": "PDF をトリミング", "preset.pdf_crop.short": "トリミング", @@ -378,6 +389,7 @@ "preset.merge_videos": "動画を結合", "preset.compress_audio": "音声を圧縮", "preset.merge_audio": "音声を結合", + "preset.merge_images": "画像を結合", "preset.remove_metadata": "メタデータを削除", "format.folder": "フォルダー", "format.category.Image": "画像", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index bb6eb21d8..54a32dbad 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -97,6 +97,7 @@ "error.ocr_needs_tesseract": "Apple Vision은 이 언어를 읽지 못해요. 설정 → 변환 엔진에서 Tesseract를 설치해 주세요", "error.too_many_frames": "프레임이 너무 많아요. 'N번째 프레임마다' 값을 늘려 주세요", "error.too_many_pictures": "한 동영상에 넣기엔 사진이 너무 많아요. 여러 번에 나눠 주세요", + "error.merged_image_too_large": "합친 이미지가 너무 커요. 사진 수를 줄이거나 더 작은 사진으로 합쳐 주세요", "error.over_size_limit": "크기 제한에 맞을 만큼 줄일 수 없어요", "error.not_enough_space": "이미지를 저장할 디스크 공간이 부족해요", "error.no_motion_photo": "이 사진에서 읽을 수 있는 동영상을 찾지 못했어요 (안드로이드 모션 포토, 또는 옆에 MOV가 있는 아이폰 라이브 포토)", @@ -341,6 +342,16 @@ "preset.merge_pdf": "PDF 병합", "merge.order_title": "병합 순서 선택", "merge.order_hint": "파일을 골라 위아래로 옮기세요. 위에서부터 순서대로 병합돼요. (Alt+↑ / Alt+↓)", + "merge.images_hint": "사진을 골라 위아래로 옮기세요. 첫 번째 사진이 맨 위에, 나란히 붙일 때는 맨 왼쪽에 와요. (Alt+↑ / Alt+↓)", + "merge.vertical": "위아래로", + "merge.horizontal": "나란히", + "merge.same_width": "너비 맞추기", + "merge.same_height": "높이 맞추기", + "merge.same_size_tip": "가장자리가 맞도록 사진 크기를 맞춰요. 끄면 사진마다 원래 크기로 가운데에 놓여요.", + "merge.gap": "간격(px)", + "merge.background": "배경", + "merge.transparent": "투명", + "merge.transparent_tip": "PNG, WEBP처럼 투명을 담는 형식에 쓰여요. JPG는 그 자리가 흰색이 돼요.", "preset.group_pdf_edit": "편집", "preset.pdf_crop": "PDF 자르기", "preset.pdf_crop.short": "자르기", @@ -388,6 +399,7 @@ "preset.merge_videos": "동영상 합치기", "preset.compress_audio": "오디오 압축", "preset.merge_audio": "오디오 합치기", + "preset.merge_images": "이미지 합치기", "preset.remove_metadata": "메타데이터 제거", "format.folder": "폴더", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 0716ee277..2a7d7753d 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -91,6 +91,7 @@ "error.no_transparency": "यो ढाँचामा पारदर्शिता हुँदैन, त्यसैले काट्नुपर्ने केही छैन", "error.too_many_frames": "छविका रूपमा सुरक्षित गर्न धेरै फ्रेमहरू छन्, \"प्रत्येक N-औं फ्रेम\" बढाउनुहोस्", "error.too_many_pictures": "एउटा भिडियोका लागि धेरै तस्बिरहरू छन्, तिनलाई केही भागमा बाँड्नुहोस्", + "error.merged_image_too_large": "मर्ज गरिएको तस्बिर धेरै ठूलो हुनेछ, कम वा साना तस्बिरहरू मर्ज गर्नुहोस्", "error.over_size_limit": "यसलाई आकार सीमाभित्र आउने गरी सानो बनाउन सकिएन", "error.not_enough_space": "छविहरूका लागि डिस्कमा पर्याप्त खाली ठाउँ छैन", "error.no_motion_photo": "यस तस्बिरमा Filee ले पढ्न सक्ने कुनै भिडियो छैन (Android मोशन फोटो, वा छेउमा MOV भएको iPhone लाइभ फोटो)", @@ -331,6 +332,16 @@ "preset.merge_pdf": "PDF मर्ज गर्नुहोस्", "merge.order_title": "मर्ज क्रम मिलाउनुहोस्", "merge.order_hint": "फाइलहरूलाई इच्छित क्रममा मिलाउन तान्नुहोस् र छाड्नुहोस्", + "merge.images_hint": "तस्बिर छानेर माथि वा तल सार्नुहोस्। पहिलो तस्बिर सबैभन्दा माथि, वा छेउछेउमा राख्दा सबैभन्दा बायाँ आउँछ। (Alt+↑ / Alt+↓)", + "merge.vertical": "माथिदेखि तल", + "merge.horizontal": "छेउछेउमा", + "merge.same_width": "उही चौडाइ", + "merge.same_height": "उही उचाइ", + "merge.same_size_tip": "किनारा मिल्ने गरी तस्बिरहरूको आकार मिलाउँछ। बन्द गर्दा हरेक तस्बिर आफ्नै आकारमा बीचमा रहन्छ।", + "merge.gap": "बीचको दूरी (px)", + "merge.background": "पृष्ठभूमि", + "merge.transparent": "पारदर्शी", + "merge.transparent_tip": "PNG, WEBP र पारदर्शिता राख्ने अन्य ढाँचाका लागि। JPG मा त्यो ठाउँ सेतो हुन्छ।", "preset.group_pdf_edit": "सम्पादन", "preset.pdf_crop": "PDF क्रप गर्नुहोस्", "preset.pdf_crop.short": "क्रप", @@ -378,6 +389,7 @@ "preset.merge_videos": "भिडियोहरू मर्ज गर्नुहोस्", "preset.compress_audio": "अडियो कम्प्रेस गर्नुहोस्", "preset.merge_audio": "अडियो मर्ज गर्नुहोस्", + "preset.merge_images": "तस्बिरहरू मर्ज गर्नुहोस्", "preset.remove_metadata": "मेटाडाटा हटाउनुहोस्", "format.folder": "फोल्डर", "format.category.Image": "छवि", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 7ea3d9132..779814007 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -97,6 +97,7 @@ "error.ocr_needs_tesseract": "Apple Vision nie czyta tego języka. Zainstaluj Tesseract w Ustawienia → Silniki", "error.too_many_frames": "Za dużo klatek do zapisania jako obrazy, zwiększ „Co N-ta klatka”", "error.too_many_pictures": "Za dużo zdjęć na jedno wideo, podziel je na kilka", + "error.merged_image_too_large": "Połączony obraz byłby za duży, połącz mniej obrazów lub mniejsze", "error.over_size_limit": "Nie da się go zmniejszyć do limitu rozmiaru", "error.not_enough_space": "Za mało wolnego miejsca na dysku na obrazy", "error.no_motion_photo": "To zdjęcie nie ma wideo, które Filee może odczytać (Motion Photo z Androida lub Live Photo z iPhone'a z plikiem MOV obok)", @@ -341,6 +342,16 @@ "preset.merge_pdf": "Połącz PDF", "merge.order_title": "Wybierz kolejność scalania", "merge.order_hint": "Wybierz plik i przesuń go w górę lub w dół. Pliki są scalane od góry do dołu. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Wybierz obraz i przesuń go w górę lub w dół. Pierwszy trafia na górę, a przy układzie obok siebie na lewo. (Alt+↑ / Alt+↓)", + "merge.vertical": "Jeden pod drugim", + "merge.horizontal": "Obok siebie", + "merge.same_width": "Ta sama szerokość", + "merge.same_height": "Ta sama wysokość", + "merge.same_size_tip": "Skaluje obrazy tak, by ich krawędzie się zrównały. Wyłączone: każdy zachowuje swój rozmiar, wyśrodkowany.", + "merge.gap": "Odstęp (px)", + "merge.background": "Tło", + "merge.transparent": "Przezroczyste", + "merge.transparent_tip": "Dla PNG, WEBP i innych formatów z przezroczystością. W JPG to miejsce będzie białe.", "preset.group_pdf_edit": "Edytuj", "preset.pdf_crop": "Przytnij PDF", "preset.pdf_crop.short": "Przytnij", @@ -388,6 +399,7 @@ "preset.merge_videos": "Połącz wideo", "preset.compress_audio": "Kompresuj dźwięk", "preset.merge_audio": "Połącz dźwięk", + "preset.merge_images": "Połącz obrazy", "preset.remove_metadata": "Usuń metadane", "format.folder": "Folder", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 140aa9541..ab33d83d6 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -97,6 +97,7 @@ "error.ocr_needs_tesseract": "O Apple Vision não lê este idioma. Instale o Tesseract em Configurações → Motores", "error.too_many_frames": "Quadros demais para salvar como imagens. Aumente “A cada N quadros”", "error.too_many_pictures": "Imagens demais para um vídeo; divida-as em vários", + "error.merged_image_too_large": "A imagem final ficaria grande demais; junte menos imagens ou imagens menores", "error.over_size_limit": "Não foi possível deixá-lo pequeno o suficiente para o limite de tamanho", "error.not_enough_space": "Não há espaço livre suficiente em disco para as imagens", "error.no_motion_photo": "Esta foto não tem um vídeo que o Filee consiga ler (uma Motion Photo do Android, ou uma Live Photo do iPhone com o MOV ao lado)", @@ -341,6 +342,16 @@ "preset.merge_pdf": "Mesclar PDF", "merge.order_title": "Escolha a ordem da mesclagem", "merge.order_hint": "Selecione um arquivo e mova para cima ou para baixo. Os arquivos são mesclados de cima para baixo. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Selecione uma imagem e mova para cima ou para baixo. A primeira fica no topo, ou à esquerda lado a lado. (Alt+↑ / Alt+↓)", + "merge.vertical": "De cima para baixo", + "merge.horizontal": "Lado a lado", + "merge.same_width": "Mesma largura", + "merge.same_height": "Mesma altura", + "merge.same_size_tip": "Redimensiona as imagens para alinhar as bordas. Desligado: cada uma mantém o tamanho, centralizada.", + "merge.gap": "Espaço (px)", + "merge.background": "Fundo", + "merge.transparent": "Transparente", + "merge.transparent_tip": "Para PNG, WEBP e outros formatos com transparência. Em JPG esse espaço fica branco.", "preset.group_pdf_edit": "Editar", "preset.pdf_crop": "Recortar PDF", "preset.pdf_crop.short": "Recortar", @@ -388,6 +399,7 @@ "preset.merge_videos": "Juntar vídeos", "preset.compress_audio": "Comprimir áudio", "preset.merge_audio": "Juntar áudio", + "preset.merge_images": "Juntar imagens", "preset.remove_metadata": "Remover metadados", "format.folder": "Pasta", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 053279162..5c1a8d158 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -97,6 +97,7 @@ "error.ocr_needs_tesseract": "O Apple Vision não consegue ler este idioma. Instala o Tesseract em Definições → Motores", "error.too_many_frames": "Há demasiados fotogramas para guardar como imagens. Aumenta o valor de «A cada N fotogramas»", "error.too_many_pictures": "Há demasiadas imagens para um só vídeo. Divide-as em vários vídeos", + "error.merged_image_too_large": "A imagem final seria demasiado grande. Junta menos imagens ou imagens mais pequenas", "error.over_size_limit": "Não foi possível torná-lo suficientemente pequeno para o limite de tamanho", "error.not_enough_space": "Não há espaço livre suficiente no disco para as imagens", "error.no_motion_photo": "Esta fotografia não tem um vídeo que o Filee consiga ler (no interior de uma Motion Photo de Android ou num MOV junto a uma Live Photo de iPhone)", @@ -341,6 +342,16 @@ "preset.merge_pdf": "Juntar PDF", "merge.order_title": "Escolher a ordem dos ficheiros", "merge.order_hint": "Seleciona um ficheiro e move-o para cima ou para baixo. Os ficheiros são combinados pela ordem da lista, de cima para baixo. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Seleciona uma imagem e move-a para cima ou para baixo. A primeira fica no topo ou, lado a lado, à esquerda. (Alt+↑ / Alt+↓)", + "merge.vertical": "De cima para baixo", + "merge.horizontal": "Lado a lado", + "merge.same_width": "Mesma largura", + "merge.same_height": "Mesma altura", + "merge.same_size_tip": "Redimensiona as imagens para alinhar os contornos. Desligado: cada uma mantém o seu tamanho, centrada.", + "merge.gap": "Espaçamento (px)", + "merge.background": "Fundo", + "merge.transparent": "Transparente", + "merge.transparent_tip": "Para PNG, WEBP e outros formatos com transparência. Num JPG esse espaço fica branco.", "preset.group_pdf_edit": "Editar", "preset.pdf_crop": "Recortar PDF", "preset.pdf_crop.short": "Recortar", @@ -388,6 +399,7 @@ "preset.merge_videos": "Juntar vídeos", "preset.compress_audio": "Comprimir áudio", "preset.merge_audio": "Juntar áudio", + "preset.merge_images": "Juntar imagens", "preset.remove_metadata": "Remover metadados", "format.folder": "Pasta", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index c6b231493..648c1d4c6 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -97,6 +97,7 @@ "error.ocr_needs_tesseract": "Apple Vision не читает этот язык. Установите Tesseract в разделе «Настройки → Движки»", "error.too_many_frames": "Слишком много кадров для сохранения картинками — увеличьте «Каждый N-й кадр»", "error.too_many_pictures": "Слишком много картинок для одного видео — разделите их на несколько", + "error.merged_image_too_large": "Объединённое изображение получится слишком большим — объедините меньше изображений или изображения поменьше", "error.over_size_limit": "Не удалось уменьшить его до предела размера", "error.not_enough_space": "Не хватает места на диске для картинок", "error.no_motion_photo": "В этом фото нет видео, которое Filee может прочитать (Motion Photo с Android или Live Photo с iPhone с файлом MOV рядом)", @@ -341,6 +342,16 @@ "preset.merge_pdf": "Объединить PDF", "merge.order_title": "Выберите порядок объединения", "merge.order_hint": "Выберите файл и переместите его вверх или вниз. Файлы объединяются сверху вниз. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Выберите изображение и переместите его вверх или вниз. Первое окажется сверху, а при размещении рядом — слева. (Alt+↑ / Alt+↓)", + "merge.vertical": "Сверху вниз", + "merge.horizontal": "Рядом", + "merge.same_width": "Одинаковая ширина", + "merge.same_height": "Одинаковая высота", + "merge.same_size_tip": "Масштабирует изображения, чтобы края совпадали. Если выключено, каждое сохраняет свой размер и стоит по центру.", + "merge.gap": "Отступ (px)", + "merge.background": "Фон", + "merge.transparent": "Прозрачный", + "merge.transparent_tip": "Для PNG, WEBP и других форматов с прозрачностью. В JPG это место будет белым.", "preset.group_pdf_edit": "Изменить", "preset.pdf_crop": "Обрезать PDF", "preset.pdf_crop.short": "Обрезать", @@ -388,6 +399,7 @@ "preset.merge_videos": "Объединить видео", "preset.compress_audio": "Сжать аудио", "preset.merge_audio": "Объединить аудио", + "preset.merge_images": "Объединить изображения", "preset.remove_metadata": "Удалить метаданные", "format.folder": "Папка", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index bd33fbba3..7be0d1253 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -91,6 +91,7 @@ "error.no_transparency": "ฟอร์แมตนี้ไม่มีความโปร่งใส จึงไม่มีขอบให้ตัดออก", "error.too_many_frames": "มีจำนวนเฟรมมากเกินกว่าจะบันทึกเป็นรูปภาพได้ ลองเพิ่มค่า \"บันทึกทุกๆ N เฟรม\"", "error.too_many_pictures": "รูปภาพมากเกินไปสำหรับวิดีโอเดียว ลองแบ่งเป็นหลายวิดีโอ", + "error.merged_image_too_large": "รูปที่รวมแล้วจะใหญ่เกินไป ลองรวมรูปให้น้อยลงหรือใช้รูปที่เล็กลง", "error.over_size_limit": "ทำให้เล็กพอสำหรับขนาดสูงสุดไม่ได้", "error.not_enough_space": "พื้นที่ว่างบนดิสก์ไม่เพียงพอสำหรับการบันทึกรูปภาพ", "error.no_motion_photo": "รูปภาพนี้ไม่มีวิดีโอที่ Filee สามารถอ่านได้ (เช่น ภาพเคลื่อนไหวของ Android หรือ Live Photo ของ iPhone ที่มีไฟล์ MOV อยู่คู่กัน)", @@ -331,6 +332,16 @@ "preset.merge_pdf": "รวมไฟล์ PDF", "merge.order_title": "เลือกลำดับการรวมไฟล์", "merge.order_hint": "เลือกไฟล์แล้วเลื่อนขึ้นหรือลง ไฟล์จะรวมจากบนลงล่าง (Alt+↑ / Alt+↓)", + "merge.images_hint": "เลือกรูปแล้วเลื่อนขึ้นหรือลง รูปแรกจะอยู่บนสุด หรืออยู่ซ้ายสุดเมื่อวางเรียงข้างกัน (Alt+↑ / Alt+↓)", + "merge.vertical": "บนลงล่าง", + "merge.horizontal": "เรียงข้างกัน", + "merge.same_width": "ความกว้างเท่ากัน", + "merge.same_height": "ความสูงเท่ากัน", + "merge.same_size_tip": "ปรับขนาดรูปให้ขอบตรงกัน ถ้าปิด แต่ละรูปจะคงขนาดเดิมและอยู่ตรงกลาง", + "merge.gap": "ระยะห่าง (px)", + "merge.background": "พื้นหลัง", + "merge.transparent": "โปร่งใส", + "merge.transparent_tip": "สำหรับ PNG, WEBP และรูปแบบอื่นที่เก็บความโปร่งใสได้ ใน JPG ส่วนนั้นจะเป็นสีขาว", "preset.group_pdf_edit": "แก้ไข", "preset.pdf_crop": "ครอบตัด PDF", "preset.pdf_crop.short": "ครอบตัด", @@ -378,6 +389,7 @@ "preset.merge_videos": "รวมวิดีโอ", "preset.compress_audio": "บีบอัดเสียง", "preset.merge_audio": "รวมไฟล์เสียง", + "preset.merge_images": "รวมรูปภาพ", "preset.remove_metadata": "ลบเมทาดาตา", "format.folder": "โฟลเดอร์", "format.category.Image": "รูปภาพ", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 0a0ddf6c7..13899f7d3 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -91,6 +91,7 @@ "error.ocr_needs_tesseract": "Apple Vision bu dili okuyamıyor. Ayarlar → Dönüştürme motorları bölümünden Tesseract kurun", "error.too_many_frames": "Görsel olarak kaydedilecek çok fazla kare var; “Her N'inci kare” değerini artırın", "error.too_many_pictures": "Tek video için çok fazla görsel var; birkaç gruba bölün", + "error.merged_image_too_large": "Birleştirilen görsel çok büyük olur; daha az veya daha küçük görsel birleştirin", "error.over_size_limit": "Boyut sınırına sığacak kadar küçültülemiyor", "error.not_enough_space": "Görseller için yeterli boş disk alanı yok", "error.no_motion_photo": "Bu fotoğrafta Filee'nin okuyabileceği video yok (Android Motion Photo veya yanında MOV dosyası bulunan iPhone Live Photo)", @@ -331,6 +332,16 @@ "preset.merge_pdf": "PDF birleştir", "merge.order_title": "Birleştirme sırasını seçin", "merge.order_hint": "Bir dosya seçip yukarı veya aşağı taşıyın. Dosyalar yukarıdan aşağıya birleştirilir. (Alt+↑ / Alt+↓)", + "merge.images_hint": "Bir görsel seçip yukarı veya aşağı taşıyın. İlk görsel en üste, yan yana dizilince en sola gelir. (Alt+↑ / Alt+↓)", + "merge.vertical": "Alt alta", + "merge.horizontal": "Yan yana", + "merge.same_width": "Aynı genişlik", + "merge.same_height": "Aynı yükseklik", + "merge.same_size_tip": "Görselleri kenarları hizalanacak şekilde ölçekler. Kapalıyken her görsel kendi boyutunda ortalanır.", + "merge.gap": "Boşluk (px)", + "merge.background": "Arka plan", + "merge.transparent": "Saydam", + "merge.transparent_tip": "PNG, WEBP ve saydamlığı koruyan diğer biçimler için. JPG'de bu alan beyaz olur.", "preset.group_pdf_edit": "Düzenle", "preset.pdf_crop": "PDF kırp", "preset.pdf_crop.short": "Kırp", @@ -378,6 +389,7 @@ "preset.merge_videos": "Videoları birleştir", "preset.compress_audio": "Sesi sıkıştır", "preset.merge_audio": "Sesleri birleştir", + "preset.merge_images": "Görselleri birleştir", "preset.remove_metadata": "Üst veriyi kaldır", "format.folder": "Klasör", "format.category.Image": "Görsel", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 7f250fbde..5d5e2034e 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -97,6 +97,7 @@ "error.ocr_needs_tesseract": "Apple Vision 无法识别此语言。请在“设置 → 转换引擎”中安装 Tesseract", "error.too_many_frames": "帧数太多,请调大“每 N 帧”", "error.too_many_pictures": "图片太多,无法放进一个视频,请分成几次", + "error.merged_image_too_large": "合并后的图片太大,请减少图片数量或使用更小的图片", "error.over_size_limit": "无法缩小到大小上限以内", "error.not_enough_space": "磁盘空间不足,无法保存这些图片", "error.no_motion_photo": "这张照片中没有可读取的视频(安卓动态照片,或旁边有 MOV 的 iPhone 实况照片)", @@ -341,6 +342,16 @@ "preset.merge_pdf": "合并 PDF", "merge.order_title": "选择合并顺序", "merge.order_hint": "选择文件并上下移动。文件将按从上到下的顺序合并。(Alt+↑ / Alt+↓)", + "merge.images_hint": "选择图片并上下移动。第一张放在最上面(左右排列时放在最左边)。(Alt+↑ / Alt+↓)", + "merge.vertical": "上下排列", + "merge.horizontal": "左右排列", + "merge.same_width": "统一宽度", + "merge.same_height": "统一高度", + "merge.same_size_tip": "缩放图片使边缘对齐。关闭时每张图片保持原尺寸并居中。", + "merge.gap": "间距 (px)", + "merge.background": "背景", + "merge.transparent": "透明", + "merge.transparent_tip": "适用于 PNG、WEBP 等支持透明的格式。JPG 中该区域会变成白色。", "preset.group_pdf_edit": "编辑", "preset.pdf_crop": "裁剪 PDF", "preset.pdf_crop.short": "裁剪", @@ -388,6 +399,7 @@ "preset.merge_videos": "合并视频", "preset.compress_audio": "压缩音频", "preset.merge_audio": "合并音频", + "preset.merge_images": "合并图片", "preset.remove_metadata": "移除元数据", "format.folder": "文件夹", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 2d8f19781..49e1f21fb 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -97,6 +97,7 @@ "error.ocr_needs_tesseract": "Apple Vision 無法辨識此語言。請在「設定 → 轉換引擎」中安裝 Tesseract", "error.too_many_frames": "影格數太多,請調大「每 N 個影格」", "error.too_many_pictures": "圖片太多,無法放進同一部影片,請分成幾次", + "error.merged_image_too_large": "合併後的圖片太大,請減少圖片數量或使用較小的圖片", "error.over_size_limit": "無法縮小到大小上限以內", "error.not_enough_space": "磁碟空間不足,無法儲存這些圖片", "error.no_motion_photo": "這張照片中沒有可讀取的影片(Android 動態相片,或旁邊有 MOV 的 iPhone 原況照片)", @@ -341,6 +342,16 @@ "preset.merge_pdf": "合併 PDF", "merge.order_title": "選擇合併順序", "merge.order_hint": "選擇檔案並上下移動。檔案將按從上到下的順序合併。(Alt+↑ / Alt+↓)", + "merge.images_hint": "選擇圖片並上下移動。第一張放在最上面(左右排列時放在最左邊)。(Alt+↑ / Alt+↓)", + "merge.vertical": "上下排列", + "merge.horizontal": "左右排列", + "merge.same_width": "統一寬度", + "merge.same_height": "統一高度", + "merge.same_size_tip": "縮放圖片讓邊緣對齊。關閉時每張圖片保持原尺寸並置中。", + "merge.gap": "間距 (px)", + "merge.background": "背景", + "merge.transparent": "透明", + "merge.transparent_tip": "適用於 PNG、WEBP 等支援透明的格式。JPG 中該區域會變成白色。", "preset.group_pdf_edit": "編輯", "preset.pdf_crop": "裁剪 PDF", "preset.pdf_crop.short": "裁剪", @@ -388,6 +399,7 @@ "preset.merge_videos": "合併影片", "preset.compress_audio": "壓縮音訊", "preset.merge_audio": "合併音訊", + "preset.merge_images": "合併圖片", "preset.remove_metadata": "移除中繼資料", "format.folder": "資料夾", diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs index b4ee3947b..377248fea 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -168,6 +168,23 @@ private PresetJudgement Evaluate(Preset preset, IReadOnlyList format if (preset.Image.TrimTransparent && formats.All(f => !Filee.Engines.Magick.ImageEncoder.HoldsAlpha(preset.TargetFormat == BuiltInData.SameAsSource ? f.Id : preset.TargetFormat))) return new(PresetUse.Unusable, _loc["error.no_transparency"]); + // "Merge images": ImageMagick reads most pictures as they are; others need a route into PNG first. + if (preset.JoinsImages) + { + if (_catalog.All.OfType().FirstOrDefault() is not { } joiner + || preset.TargetFormat != BuiltInData.SameAsSource && !joiner.CanWrite(preset.TargetFormat)) + return new(PresetUse.Unusable, _loc["error.no_route"]); + var pictures = formats.Where(f => f.Category is FormatCategory.Image or FormatCategory.Vector).ToList(); + if (pictures.Count == 0) + return new(PresetUse.Unusable, _loc["error.images_only"]); + bool Readable(RoutePlanner planner) => pictures.Any(f => joiner.CanRead(f.Id) || planner.CanConvert(f.Id, "png")); + if (Readable(installedPlanner())) + return new(PresetUse.Usable, null); + return packageEnabling(Readable) is { } reader + ? new(PresetUse.NeedsPackage, _loc.Format("engine.reason.needs_package", reader.Name)) + : new(PresetUse.Unusable, _loc["error.no_route"]); + } + // "Merge videos" / "Merge audio" / "Video from images": FFmpeg joins the files, so a route into the target (or, // for pictures, into PNG and from PNG into a video) says whether it is installed. if (preset.JoinsIntoOneMedia) diff --git a/src/Filee.App/Services/RadialController.cs b/src/Filee.App/Services/RadialController.cs index d689f25d7..943831e63 100644 --- a/src/Filee.App/Services/RadialController.cs +++ b/src/Filee.App/Services/RadialController.cs @@ -338,8 +338,8 @@ private void Convert(int index, int sub, IReadOnlyList files, WebDrop? w return; } - // Merged PDFs, joined clips and slideshows follow the order the user picks. - if ((preset.TargetFormat == "pdf" && preset.Pdf.MergeIntoSingle || preset.JoinsIntoOneMedia) && sources.Count > 1) + // Merged PDFs, joined clips, slideshows and merged pictures follow the order the user picks. + if ((preset.TargetFormat == "pdf" && preset.Pdf.MergeIntoSingle || preset.JoinsIntoOneMedia || preset.JoinsImages) && sources.Count > 1) { Close(animate: false); _ = MergeInOrderAsync(sources, preset.Clone(), web); @@ -371,7 +371,9 @@ private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, { try { - var ordered = await windows.ChooseMergeOrderAsync(files); + var ordered = await windows.ChooseMergeOrderAsync(files, preset); + if (ordered is not null && preset.JoinsImages) + RememberMergeLayout(preset); if (ordered is not null && preset.Output.AskEveryTime && web is null) await AskThenConvertAsync(ordered, preset); else if (ordered is not null) @@ -382,7 +384,7 @@ private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, } catch (Exception ex) when (ex is not OutOfMemoryException) { - log.LogError(ex, "Could not start the ordered PDF merge"); + log.LogError(ex, "Could not start the ordered merge"); } finally { @@ -390,6 +392,32 @@ private async Task MergeInOrderAsync(IReadOnlyList files, Preset preset, } } + /// + /// "Merge images": the layout chosen in the merge order window (, a copy of the preset) + /// goes into the preset as saved, so the window opens with it next time. + /// + private void RememberMergeLayout(Preset chosen) + { + if (store.FindPreset(chosen.Id) is not { JoinsImages: true } saved) + return; + var image = saved.Image; + if (image.JoinDirection == chosen.Image.JoinDirection && image.JoinFit == chosen.Image.JoinFit + && image.JoinGap == chosen.Image.JoinGap && image.JoinBackground == chosen.Image.JoinBackground) + return; + image.JoinDirection = chosen.Image.JoinDirection; + image.JoinFit = chosen.Image.JoinFit; + image.JoinGap = chosen.Image.JoinGap; + image.JoinBackground = chosen.Image.JoinBackground; + try + { + store.SaveLibrary(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + log.LogWarning(ex, "Could not remember the layout of merged pictures"); + } + } + private async Task AskThenConvertAsync(IReadOnlyList sources, Preset preset) { try diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index c6c238796..1c16baa65 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -49,11 +49,24 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem private void PrepareWindow(Window window) => services.GetService()?.ApplyWindowClass(window); - /// Reviews the order of a PDF merge. Closing or cancelling starts no conversion. - public async Task?> ChooseMergeOrderAsync(IReadOnlyList files) + /// + /// Reviews the order of a merge (, when given, names the button). For "Merge images" the + /// layout is chosen too and written into . Closing or cancelling starts no conversion. + /// + public async Task?> ChooseMergeOrderAsync(IReadOnlyList files, Preset? preset = null) { _mergeOrder?.Close(); - var dialog = new MergeOrderWindow { DataContext = new MergeOrderViewModel(files) }; + var images = preset is { JoinsImages: true }; + var vm = new MergeOrderViewModel(files, images ? preset!.Image : null); + var dialog = new MergeOrderWindow { DataContext = vm }; + if (preset is not null) + dialog.StartMerge.Content = loc.DisplayName(preset); + if (images) + { + // Room for the layout below the list. + dialog.Height = 640; + dialog.MinHeight = 540; + } PrepareWindow(dialog); var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); dialog.Closed += (_, _) => @@ -65,6 +78,8 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem dialog.Show(); dialog.Activate(); await closed.Task; + if (dialog.Result is not null && images) + vm.ApplyTo(preset!.Image); return dialog.Result; } diff --git a/src/Filee.App/ViewModels/MergeOrderViewModel.cs b/src/Filee.App/ViewModels/MergeOrderViewModel.cs index 0cf1a743e..7710534bc 100644 --- a/src/Filee.App/ViewModels/MergeOrderViewModel.cs +++ b/src/Filee.App/ViewModels/MergeOrderViewModel.cs @@ -1,10 +1,14 @@ -// The order of a merge into one PDF (issue #268): the files start sorted by name the way people count ("page 2" before -// "page 10"), and the user moves them before the merge starts. Moving a row changes only this copy of the selection. +// The order of a merge into one PDF (issue #268), one video or one image (#645): the files start sorted by name the way +// people count ("page 2" before "page 10"), and the user moves them before the merge starts. Moving a row changes only +// this copy of the selection. Pictures merged into one image also get their layout here: one under another or side by +// side, the same width or their own sizes, the gap and the background. using System.Collections.ObjectModel; +using Avalonia.Media; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Filee.Core.Platform; +using Filee.Core.Presets; using Filee.Core.Web; namespace Filee.App.ViewModels; @@ -19,17 +23,91 @@ public sealed class MergeSource(string path) public sealed partial class MergeOrderViewModel : ObservableObject { - public MergeOrderViewModel(IReadOnlyList files) + /// The files to merge. + /// + /// "Merge images" (#645): the layout the pictures are merged with last time, which the window also offers to change. + /// Null for other merges. + /// + public MergeOrderViewModel(IReadOnlyList files, ImageOptions? images = null) { Files = new(files .OrderBy(file => System.IO.Path.GetFileName(file), NaturalComparer.Instance) .ThenBy(file => file, NaturalComparer.Instance) .Select(file => new MergeSource(file))); SelectedFile = Files.FirstOrDefault(); + if (images is null) + return; + MergesImages = true; + _sideBySide = images.JoinDirection == ImageJoinDirection.Horizontal; + _sameSize = images.JoinFit == ImageJoinFit.SameSize; + _gap = images.JoinSpacing; + _transparent = string.Equals(images.JoinBackground, ImageOptions.TransparentJoin, StringComparison.OrdinalIgnoreCase); + _backgroundColor = !_transparent && Color.TryParse(images.JoinBackground, out var colour) ? Color.FromRgb(colour.R, colour.G, colour.B) : Colors.White; + _backgroundHex = Hex(_backgroundColor); } public ObservableCollection Files { get; } + /// The files are pictures that become one image: the window shows the layout too. + public bool MergesImages { get; } + + /// Side by side (the first on the left); otherwise one under another (the first at the top). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(TopToBottom))] + private bool _sideBySide; + + public bool TopToBottom + { + get => !SideBySide; + set => SideBySide = !value; + } + + /// Pictures of different sizes scaled to the same width (or height side by side); otherwise kept, centred. + [ObservableProperty] + private bool _sameSize = true; + + /// Pixels between two pictures. + [ObservableProperty] + private int _gap; + + [ObservableProperty] + private Color _backgroundColor = Colors.White; + + /// as #RRGGBB, to type one (as on the Theme page). + [ObservableProperty] + private string _backgroundHex = "#FFFFFF"; + + partial void OnBackgroundColorChanged(Color value) + { + var hex = Hex(value); + if (!string.Equals(BackgroundHex, hex, StringComparison.OrdinalIgnoreCase)) + BackgroundHex = hex; + } + + partial void OnBackgroundHexChanged(string value) + { + if (Color.TryParse(value.Trim(), out var colour) && Color.FromRgb(colour.R, colour.G, colour.B) is var opaque && opaque != BackgroundColor) + BackgroundColor = opaque; + } + + private static string Hex(Color colour) => $"#{colour.R:X2}{colour.G:X2}{colour.B:X2}"; + + /// A see-through background instead of (in a format that holds one). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PicksColor))] + private bool _transparent; + + public bool PicksColor => !Transparent; + + /// Writes the layout chosen here into . + public void ApplyTo(ImageOptions image) + { + image.JoinDirection = SideBySide ? ImageJoinDirection.Horizontal : ImageJoinDirection.Vertical; + image.JoinFit = SameSize ? ImageJoinFit.SameSize : ImageJoinFit.Original; + image.JoinGap = Math.Clamp(Gap, 0, ImageOptions.MaxJoinGap); + image.JoinBackground = Transparent ? ImageOptions.TransparentJoin : Hex(BackgroundColor); + } + [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(MoveUpCommand))] [NotifyCanExecuteChangedFor(nameof(MoveDownCommand))] diff --git a/src/Filee.App/Views/MergeOrderWindow.axaml b/src/Filee.App/Views/MergeOrderWindow.axaml index aa913b685..5f5cb5025 100644 --- a/src/Filee.App/Views/MergeOrderWindow.axaml +++ b/src/Filee.App/Views/MergeOrderWindow.axaml @@ -1,5 +1,6 @@ - + + + + + + + + + + + + @@ -36,8 +84,50 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Filee.Core/Conversion/IConverter.cs b/src/Filee.Core/Conversion/IConverter.cs index 1d835c29b..7dfbce73e 100644 --- a/src/Filee.Core/Conversion/IConverter.cs +++ b/src/Filee.Core/Conversion/IConverter.cs @@ -259,6 +259,30 @@ Task JoinAsync(IReadOnlyList inputPaths, string outputPath, string forma string workDirectory, IProgress? progress, CancellationToken cancellationToken); } +/// +/// Puts several pictures into one image, one under another or side by side ("Merge images"). Implemented by the +/// ImageMagick engine; uses it when a preset merges pictures (). +/// +public interface IImageJoiner +{ + /// True if pictures of are read as they are; others become PNG first. + bool CanRead(string format); + + /// True if the merged image can be written as . + bool CanWrite(string format); + + /// + /// Writes the pictures of , in order, into one image laid out as + /// say (, …). Refuses with + /// error.merged_image_too_large when the image would be too large to make. + /// + /// Each picture and its format id. + /// File to create. + /// Target format id, one takes. + Task JoinAsync(IReadOnlyList<(string Path, string Format)> inputs, string outputPath, string format, ImageOptions options, + IProgress? progress, CancellationToken cancellationToken); +} + /// /// Packs several files, unchanged, into one archive ("Compress into one archive"). Implemented by the archive engine; /// uses it when a preset sets . diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs index f826f6697..b8d8ab016 100644 --- a/src/Filee.Core/Conversion/JobQueue.cs +++ b/src/Filee.Core/Conversion/JobQueue.cs @@ -113,9 +113,13 @@ private async Task RunJobAsync(ConversionJob job) var multiPageTiff = job.Preset.Image.MultiPageTiff && job.Preset.TargetFormat == "tiff" && _tiffMerger is not null; // Clips back to back, or pictures one after the other, in the order they were dropped. var join = job.Preset.JoinsIntoOneMedia && _mediaJoiner is not null; + // Pictures one under another or side by side, in the order they were dropped. + var imageJoiner = job.Preset.JoinsImages ? _catalog.All.OfType().FirstOrDefault() : null; if (join) await RunJoinedMediaAsync(job, planner, workDir); + else if (imageJoiner is not null) + await RunMergedImagesAsync(job, planner, workDir, imageJoiner); else if (merge) await RunMergedAsync(job, planner, workDir); else if (multiPageTiff) @@ -361,8 +365,11 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), Notify(job); } - /// Share of a slideshow's progress that preparing its pictures takes; encoding the video fills the rest. - private const double SlidesReadyShare = 0.3; + /// + /// Share of a slideshow's (or a merge of pictures') progress that preparing its pictures takes; encoding the video + /// (or merging) fills the rest. + /// + private const double PicturesReadyShare = 0.3; /// /// "Merge videos" / "Merge audio": the videos or audio files go into one file as they are. "Video from images": @@ -406,7 +413,7 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), parts[i] = source.Id == "png" && !job.Preset.Image.TurnsOrMirrors ? [file.SourcePath] : await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false, intermediate: "png", - ceiling: SlidesReadyShare); + ceiling: PicturesReadyShare); return; } // A video's sound can join an audio file; a picture or a document joins nothing. @@ -428,9 +435,73 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), return; } + await WriteJoinedAsync(job, joined, inputs.Count, target.PrimaryExtension, + (output, progress) => _mediaJoiner!.JoinAsync(inputs, output, target.Id, job.Preset, workDir, progress, job.CancellationToken)); + } + + /// + /// "Merge images": the pictures go, in order, into one image named after the first, in the first one's format for + /// "same as source" (PNG when that one can't be written). A picture the joiner doesn't read as it is (ICNS, JPEG XR, + /// SVG, …) becomes a plain PNG in the work directory first; the preset's options apply once, as the pictures merge. + /// + private async Task RunMergedImagesAsync(ConversionJob job, RoutePlanner planner, string workDir, IImageJoiner joiner) + { + var plain = new Preset { TargetFormat = "png", Pdf = { RenderDpi = job.Preset.Pdf.RenderDpi } }; + var parts = new (string Path, string Format)?[job.Files.Count]; + await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), + new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, + async (i, _) => + { + var file = job.Files[i]; + if (Stopped(file)) + return; + var source = FormatRegistry.Detect(file.SourcePath); + if (source is null) + FailFile(job, file, "error.unsupported_source", "." + FormatRegistry.ExtensionOf(file.SourcePath)); + else if (IsEmptyFile(file.SourcePath)) + FailFile(job, file, "error.empty_file", null); + else if (source.Category is not (FormatCategory.Image or FormatCategory.Vector)) + FailFile(job, file, "error.images_only", null); + else if (joiner.CanRead(source.Id)) + parts[i] = (file.SourcePath, source.Id); + else if (await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false, intermediate: "png", + ceiling: PicturesReadyShare, options: plain) is [var png, ..]) + parts[i] = (png, "png"); + }); + + var joined = job.Files.Where((f, i) => parts[i] is not null && f.State != FileState.Failed).ToList(); + var inputs = parts.OfType<(string Path, string Format)>().ToList(); + if (joined.Count == 0 || job.CancellationToken.IsCancellationRequested) + { + Notify(job); + return; + } + + var target = job.Preset.TargetFormat; + if (target == BuiltInData.SameAsSource) + target = FormatRegistry.Detect(joined[0].SourcePath) is { } first && joiner.CanWrite(first.Id) ? first.Id : "png"; + else if (!joiner.CanWrite(target)) + { + foreach (var f in joined) + FailFile(job, f, "error.no_route", $"{FormatRegistry.Detect(f.SourcePath)?.Id} → {target}"); + return; + } + + await WriteJoinedAsync(job, joined, inputs.Count, FormatRegistry.Get(target).PrimaryExtension, + (output, progress) => joiner.JoinAsync(inputs, output, target, job.Preset.Image, progress, job.CancellationToken)); + } + + /// + /// Writes the one file several sources become (merged videos, merged pictures), named after the first of + /// . Their progress moves together; a write that fails or is cancelled is undone. + /// + /// Writes the file at the path it is given. + private async Task WriteJoinedAsync(ConversionJob job, List joined, int inputCount, string extension, + Func, Task> write) + { var first = joined[0]; var allocator = CreateFinalAllocator(job, first.SourcePath, 1, oneFile: true); - var output = allocator.Allocate(target.PrimaryExtension); + var output = allocator.Allocate(extension); if (output is null) { foreach (var f in joined) @@ -459,8 +530,8 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), try { - await _mediaJoiner!.JoinAsync(inputs, output, target.Id, job.Preset, workDir, progress, job.CancellationToken); - SizeLimit.Check(job.Preset, [output]); // joins aren't fitted: one over the limit is refused (#553) + await write(output, progress); + SizeLimit.Check(job.Preset, [output]); // joined media aren't fitted: one over the limit is refused (#553) first.Outputs.Add(output); foreach (var f in joined) { @@ -477,14 +548,14 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count), catch (ConversionRefusedException ex) { allocator.Discard(); - _log.LogInformation("Joining {Count} file(s) into {Output} was refused: {Reason}", inputs.Count, output, ex.Message); + _log.LogInformation("Joining {Count} file(s) into {Output} was refused: {Reason}", inputCount, output, ex.Message); foreach (var f in joined) FailFile(job, f, ex.ErrorKey, ex.Detail); } catch (Exception ex) { allocator.Discard(); - _log.LogWarning(ex, "Joining {Count} file(s) into {Output} failed", inputs.Count, output); + _log.LogWarning(ex, "Joining {Count} file(s) into {Output} failed", inputCount, output); foreach (var f in joined) FailFile(job, f, "error.conversion_failed", ex.Message); } @@ -659,13 +730,15 @@ private void CopyDates((DateTime Created, DateTime Modified) dates, IEnumerable< /// Converts one source file along its planned route. /// With false, results stay in the work directory as /// files (PDF for merging, TIFF pages for a multi-page TIFF). is the file's progress once - /// the route is done (a slideshow's pictures leave the rest to the video). + /// the route is done (a slideshow's pictures leave the rest to the video). is the preset + /// the route runs with, the job's unless given (the pictures of a merge become plain PNGs first). /// private async Task> RunFileAsync( ConversionJob job, FileResult file, int index, RoutePlanner planner, string workDir, bool finalOutput, - string intermediate = "pdf", double ceiling = 1) + string intermediate = "pdf", double ceiling = 1, Preset? options = null) { var ct = job.CancellationToken; + var runWith = options ?? job.Preset; if (Stopped(file)) return []; var source = FormatRegistry.Detect(file.SourcePath); @@ -677,19 +750,19 @@ private async Task> RunFileAsync( return Fail(file, "error.empty_file", null); var target = !finalOutput ? intermediate - : job.Preset.TargetFormat == BuiltInData.SameAsSource ? source.Id - : job.Preset.TargetFormat; + : runWith.TargetFormat == BuiltInData.SameAsSource ? source.Id + : runWith.TargetFormat; // "Save every frame": a video becomes a folder of pictures in one step of its own; "Extract Live/Motion Photo // video" likewise takes a photo's video, and "Live / Motion Photo" makes one of a video. Never for an // intermediate file, and never on the way to another format (video → JPG is otherwise one still frame, photo → // MP4 a video of the still picture). - var frames = finalOutput && job.Preset.SavesFramesOf(source.Id); - var motionPhoto = finalOutput && job.Preset.ExtractsMotionOf(source.Id); - var livePhoto = finalOutput && job.Preset.MakesLivePhotoOf(source.Id); + var frames = finalOutput && runWith.SavesFramesOf(source.Id); + var motionPhoto = finalOutput && runWith.ExtractsMotionOf(source.Id); + var livePhoto = finalOutput && runWith.MakesLivePhotoOf(source.Id); // "Recognize text": Tesseract writes a searchable PDF first, read on into DOCX, TXT, … from there. Also for the // PDFs that are merged into one. - var ocr = (finalOutput || target == "pdf") && job.Preset.RecognizesTextOf(source.Id); + var ocr = (finalOutput || target == "pdf") && runWith.RecognizesTextOf(source.Id); var route = planner.Plan(source.Id, target, frames, motionPhoto: motionPhoto, livePhoto: livePhoto, ocr: ocr); if (route is null) return Fail(file, "error.no_route", $"{source.Id} → {target}"); @@ -721,10 +794,10 @@ private async Task> RunFileAsync( // Rotate / flip: only an engine that turns images does it. Without one the file would come out unturned // (a PDF or a video re-encoded "as rotated"); with two in the route (EPS → PNG → JPG) it would turn twice. - var turnAt = job.Preset.Image.TurnsOrMirrors ? route.FirstTransformStep() : -1; - if (job.Preset.Image.TurnsOrMirrors && turnAt < 0) + var turnAt = runWith.Image.TurnsOrMirrors ? route.FirstTransformStep() : -1; + if (runWith.Image.TurnsOrMirrors && turnAt < 0) return Fail(file, "error.cannot_turn", $"{source.Id} → {target}"); - var unturned = turnAt < 0 ? job.Preset : Unturned(job.Preset); + var unturned = turnAt < 0 ? runWith : Unturned(runWith); file.State = FileState.Running; Notify(job); @@ -739,7 +812,7 @@ private async Task> RunFileAsync( { IReadOnlyList inputs = [file.SourcePath]; // "Save as ZIP": the last step writes into the work folder and the files are packed into one ZIP afterwards. - var zipResult = finalOutput && job.Preset.Output.ZipResult; + var zipResult = finalOutput && runWith.Output.ZipResult; for (var s = 0; s < route.Steps.Count; s++) { var step = route.Steps[s]; @@ -769,7 +842,7 @@ private async Task> RunFileAsync( { // Turned once, in step turnAt: every other step gets the preset without rotation and flip, // also one before it whose engine applies image options but doesn't say it turns. - var preset = s == turnAt ? job.Preset : unturned; + var preset = s == turnAt ? runWith : unturned; // Playback speed changes once, in the step that writes the output: an animation passed from one // engine to another would otherwise change speed in both. if (preset.Image.ChangesSpeed && !(isLast && finalOutput)) @@ -797,15 +870,15 @@ private async Task> RunFileAsync( } // Under "Save as ZIP" the output is still in the work folder: the source copied over it goes into the ZIP. - var copiedSource = finalOutput && job.Preset.Output.OnlyIfSmaller && inputs.Count == 1 && CopyIfNotSmaller(file.SourcePath, inputs[0]); + var copiedSource = finalOutput && runWith.Output.OnlyIfSmaller && inputs.Count == 1 && CopyIfNotSmaller(file.SourcePath, inputs[0]); if (copiedSource) file.NoticeKey = "notice.pdf_not_smaller"; // Watermark, page numbers, password: on every PDF this file became, from whichever engine. - if (finalOutput && job.Preset.TargetFormat == "pdf") - await FinishPdfsAsync(job.Preset, inputs, ct); + if (finalOutput && runWith.TargetFormat == "pdf") + await FinishPdfsAsync(runWith, inputs, ct); // The size limit (#553): a result the engine couldn't fit (HEIC, a render, an animation) isn't handed over. if (finalOutput) - SizeLimit.Check(job.Preset, inputs); + SizeLimit.Check(runWith, inputs); if (zipResult && inputs.Count > 0) inputs = PackZip(job, file, index, inputs, ct); @@ -814,7 +887,7 @@ private async Task> RunFileAsync( inputs = [NextToSource(job, file, index, inputs[0], final)]; // The source's dates are read now: "Replace the original" may send it to the Trash below. - var dates = finalOutput && job.Preset.Output.KeepDates ? DatesOf(file.SourcePath) : null; + var dates = finalOutput && runWith.Output.KeepDates ? DatesOf(file.SourcePath) : null; if (finalOutput && !zipResult && inputs.Count == 1) inputs = [ReplaceOriginal(job, file, source.Id, inputs[0], copiedSource)]; diff --git a/src/Filee.Core/Presets/AskedLocation.cs b/src/Filee.Core/Presets/AskedLocation.cs index f5af3b52c..ff8c170be 100644 --- a/src/Filee.Core/Presets/AskedLocation.cs +++ b/src/Filee.Core/Presets/AskedLocation.cs @@ -8,14 +8,15 @@ public static class AskedLocation { /// /// The batch makes one file, so the dialog asks for its name: one source, or several that become one (a merged - /// PDF, a multi-page TIFF, one archive, joined videos). Otherwise it asks for a folder. + /// PDF, a multi-page TIFF, one archive, joined videos, merged pictures). Otherwise it asks for a folder. /// public static bool MakesOneResult(Preset preset, int sources) => sources == 1 || preset.TargetFormat == "pdf" && preset.Pdf.MergeIntoSingle || preset.Image.MultiPageTiff && preset.TargetFormat == "tiff" || preset.PacksIntoOneArchive - || preset.JoinsIntoOneMedia; + || preset.JoinsIntoOneMedia + || preset.JoinsImages; /// /// The name the save dialog suggests for : the preset's name pattern and renames, and the diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs index ce8880745..a0d0817de 100644 --- a/src/Filee.Core/Presets/BuiltInData.cs +++ b/src/Filee.Core/Presets/BuiltInData.cs @@ -115,6 +115,7 @@ .. MotionPhotoPresets(), .. LivePhotoPresets(), .. ClipPresets(), .. MediaToolPresets(), + .. MergeImagesPresets(), new() { Id = "to-docx", Name = "DOCX", TargetFormat = "docx" }, new() { Id = "to-hwpx", Name = "HWPX", TargetFormat = "hwpx" }, .. HwpPresets(), @@ -262,6 +263,20 @@ public static List MediaToolPresets() => }, ]; + /// + /// "Merge images" (issue #645, added in settings schema 53): the dropped pictures, in the order set in the merge + /// order window, as one image, one under another or side by side, in the first picture's format. Quality 90 as the + /// JPG preset. On the back of the images donut next to "Merge PDF", where it took the slot of "GIF". + /// + public static List MergeImagesPresets() => + [ + new() + { + Id = "merge-images", NameKey = "preset.merge_images", TargetFormat = SameAsSource, + Image = { JoinIntoOne = true, Quality = 90 }, Output = { FileNamePattern = "{name}_merged" }, + }, + ]; + /// /// "Compress PDF": pictures at 150 dpi, the rest compressed losslessly (added in settings schema 20). On no default /// donut, the PDF donut is full: users add it in Settings → Toolbar. @@ -528,9 +543,10 @@ public static List CreateProfiles() => Id = "images", NameKey = "profile.images", Extensions = ExtensionsOf(FormatCategory.Image), PresetIds = ["to-png", "to-jpg", "to-webp", "to-pdf", "trace-svg", "trace-line-art", "to-tiff", "to-bmp", "to-ico", "half-size"], - // Full (ToolbarProfile.MaxSlices): "Extract Live Photo video" stays in the library, on no donut. - BackPresetIds = ["tool-crop", "rotate", "flip", "remove-background", "trim-transparent", "grayscale", "to-avif", "to-gif", "merge-pdf", - "compress-image", "remove-metadata", "zip-all"], + // Full (ToolbarProfile.MaxSlices): "Extract Live Photo video" stays in the library, on no donut, and "GIF" + // since "Merge images" took its slot (settings schema 53): a still picture as GIF is the least wanted here. + BackPresetIds = ["tool-crop", "rotate", "flip", "remove-background", "trim-transparent", "grayscale", "to-avif", "merge-images", + "merge-pdf", "compress-image", "remove-metadata", "zip-all"], }, new() { diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs index a795927f7..63d8fc898 100644 --- a/src/Filee.Core/Presets/Preset.cs +++ b/src/Filee.Core/Presets/Preset.cs @@ -184,6 +184,15 @@ public bool RecognizesTextOf(string sourceFormat) => public bool JoinsIntoOneMedia => (Media.JoinIntoOne || Media.Slideshow) && MediaOptions.JoinTargets.Contains(TargetFormat); + /// + /// True for "Merge images": every dropped picture goes, in order, into one image () + /// of a picture format, or of the first picture's format for . + /// + [JsonIgnore] + public bool JoinsImages => + Image.JoinIntoOne + && (TargetFormat == BuiltInData.SameAsSource || Formats.FormatRegistry.FindById(TargetFormat)?.Category == Formats.FormatCategory.Image); + /// True for a . [JsonIgnore] public bool IsTool => Kind == PresetKind.Tool; @@ -288,6 +297,24 @@ public enum ImageFlip Vertical, } +/// Which way merged pictures follow each other (). +public enum ImageJoinDirection +{ + /// One under another, the first at the top. + Vertical, + /// Side by side, the first on the left. + Horizontal, +} + +/// How merged pictures of different sizes line up (). +public enum ImageJoinFit +{ + /// Scaled to the widest picture (one under another) or the tallest (side by side), so the edges line up. + SameSize, + /// Each at its own size, centred. + Original, +} + /// TIFF compression schemes. public enum TiffCompression { @@ -406,6 +433,40 @@ public sealed class ImageOptions /// Use lossless WEBP encoding. public bool WebpLossless { get; set; } + /// + /// "Merge images": every dropped picture goes, in order, into one image named after the first (), + /// one under another or side by side (). Each picture is turned as its EXIF and + /// say first; the other options (resize, grayscale, quality …) apply to the merged image. + /// + public bool JoinIntoOne { get; set; } + + /// With : one under another or side by side. A direction from a newer Filee reads as vertical. + [JsonConverter(typeof(LenientEnumJsonConverter))] + public ImageJoinDirection JoinDirection { get; set; } + + /// With : pictures of different sizes scaled to line up, or kept as they are, centred. + [JsonConverter(typeof(LenientEnumJsonConverter))] + public ImageJoinFit JoinFit { get; set; } + + /// Largest . + public const int MaxJoinGap = 1000; + + /// With : pixels of between two pictures (0 to ). + public int JoinGap { get; set; } + + /// for a see-through background (in a format that holds one). + public const string TransparentJoin = "transparent"; + + /// + /// With : the colour of the gaps and of the room around smaller pictures, #RRGGBB, or + /// . A format without transparency (JPG) gets there instead. + /// + public string JoinBackground { get; set; } = "#FFFFFF"; + + /// within its limits (a hand-edited preset file may hold anything). + [JsonIgnore] + public int JoinSpacing => Math.Clamp(JoinGap, 0, MaxJoinGap); + /// /// FITS → TIFF: the data as it is (the default) or stretched. Either way the TIFF follows the file's BITPIX: 8 and /// 16 bits stay so, 32-bit integer and floating-point data become 32-bit floating point. diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 731f3263e..7e1f954ff 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -123,7 +123,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 50; + public const int CurrentSchemaVersion = 53; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 6a2f68c20..f9f0a8841 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -906,9 +906,49 @@ public static bool ApplyToLibrary(int from, List presets, List presets, List profiles) + { + var changed = false; + var merge = BuiltInData.MergeImagesPresets().Single(); + if (presets.FirstOrDefault(p => p.Id == merge.Id) is { } existing) + { + if (!existing.Image.JoinIntoOne && existing.TargetFormat == merge.TargetFormat) + { + existing.Image.JoinIntoOne = true; + changed = true; + } + } + else + { + var at = presets.FindLastIndex(p => p.Id is "remove-metadata" or "merge-audio" or "merge-videos"); + presets.Insert(at < 0 ? presets.Count : at + 1, merge); + changed = true; + } + if (profiles.FirstOrDefault(p => p.Id == "images") is not { BackPresetIds: { } back } images + || back.Contains(merge.Id) || images.PresetIds.Contains(merge.Id)) + return changed; + if (back.Count < ToolbarProfile.MaxSlices) + { + InsertBefore(back, merge.Id, back.Contains("merge-pdf") ? "merge-pdf" : "zip-all"); + return true; + } + var gif = back.IndexOf("to-gif"); + if (gif < 0) + return changed; + back[gif] = merge.Id; + return true; + } + private static bool AddCalendarPreset(List presets, List profiles) { var changed = false; diff --git a/src/Filee.Engines/Magick/ImageJoiner.cs b/src/Filee.Engines/Magick/ImageJoiner.cs new file mode 100644 index 000000000..d9c8bb919 --- /dev/null +++ b/src/Filee.Engines/Magick/ImageJoiner.cs @@ -0,0 +1,175 @@ +// "Merge images" (issue #645): pictures one under another or side by side in one image, with a gap and a background +// colour between them. The layout is worked out from the pictures' sizes first, so only one picture at a time (and the +// merged image) is in memory. + +using Filee.Core.Conversion; +using Filee.Core.Presets; +using ImageMagick; + +namespace Filee.Engines.Magick; + +internal static class ImageJoiner +{ + /// + /// The largest merged image Filee makes, the size of the longest "Long image" (PdfiumConverter.MaxStackedPixels): + /// about 800 MB of memory while it is put together. + /// + internal const long MaxPixels = Pdf.PdfiumConverter.MaxStackedPixels; + + /// + /// Pictures read to measure them stay in memory up to this many pixels in all, for the merge: two or three photos are + /// read once, a long series a second time. + /// + private const long KeptPixels = 50_000_000; + + /// Where one picture goes in the merged image, at which size. + internal readonly record struct Placement(int X, int Y, uint Width, uint Height); + + /// The merged image's size and where each picture goes, in order. + internal sealed record Layout(uint Width, uint Height, IReadOnlyList Pictures); + + /// + /// Lays out pictures of (as they look, turned) as say. Refuses + /// with error.merged_image_too_large above . + /// + internal static Layout Plan(IReadOnlyList<(uint Width, uint Height)> sizes, ImageOptions options) + { + var sideBySide = options.JoinDirection == ImageJoinDirection.Horizontal; + var gap = options.JoinSpacing; + // "Across" is the side the pictures share (the width of a column, the height of a row), "along" the other. + var fitted = sizes.Select(s => (Across: (double)(sideBySide ? s.Height : s.Width), Along: (double)(sideBySide ? s.Width : s.Height))) + .ToList(); + var across = fitted.Max(s => s.Across); + var placed = fitted.Select(s => options.JoinFit == ImageJoinFit.SameSize + ? (Across: across, Along: Math.Max(1, Math.Round(s.Along * across / s.Across))) + : s) + .ToList(); + var along = placed.Sum(s => s.Along) + (double)gap * (placed.Count - 1); + if (across * along > MaxPixels) + { + var (w, h) = sideBySide ? (along, across) : (across, along); + throw new ConversionRefusedException("error.merged_image_too_large", $"{w:0} × {h:0} px"); + } + + var pictures = new List(); + double at = 0; + foreach (var (pictureAcross, pictureAlong) in placed) + { + // Centred across: only a smaller picture of "keep sizes" has room around it. + var offset = (int)Math.Floor((across - pictureAcross) / 2); + pictures.Add(sideBySide + ? new Placement((int)at, offset, (uint)pictureAlong, (uint)pictureAcross) + : new Placement(offset, (int)at, (uint)pictureAcross, (uint)pictureAlong)); + at += pictureAlong + gap; + } + return sideBySide ? new Layout((uint)along, (uint)across, pictures) : new Layout((uint)across, (uint)along, pictures); + } + + /// Writes the pictures of into one image at . + internal static void Join(IReadOnlyList<(string Path, string Format)> inputs, string outputPath, string format, ImageOptions options, + IProgress? progress, CancellationToken cancellationToken) + { + // The pictures are read with the preset's picture settings (a FITS stretch, a metafile's dpi). + var reading = new Preset { Image = options }; + var kept = new IMagickImage?[inputs.Count]; + try + { + var sizes = new (uint Width, uint Height)[inputs.Count]; + long keptPixels = 0; + Density? density = null; + for (var i = 0; i < inputs.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var picture = Read(inputs[i], reading, options); + sizes[i] = (picture.Width, picture.Height); + density ??= picture.Density; + var pixels = (long)picture.Width * picture.Height; + if (keptPixels + pixels <= KeptPixels) + { + kept[i] = picture; + keptPixels += pixels; + } + else + picture.Dispose(); + progress?.Report(0.4 * (i + 1) / inputs.Count); + } + + var layout = Plan(sizes, options); + using var merged = new MagickImage(Background(options.JoinBackground), layout.Width, layout.Height); + for (var i = 0; i < inputs.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + using var picture = kept[i] ?? Read(inputs[i], reading, options); + kept[i] = null; + var place = layout.Pictures[i]; + if (picture.Width != place.Width || picture.Height != place.Height) + picture.Resize(new MagickGeometry(place.Width, place.Height) { IgnoreAspectRatio = true }); + merged.Composite(picture, place.X, place.Y, CompositeOperator.Over); + progress?.Report(0.4 + 0.4 * (i + 1) / inputs.Count); + } + if (density is { } first) + merged.Density = first; + + // The pictures are turned already: the rest of the options (resize, grayscale, quality, a background for a + // format without transparency, what the format stores) apply to the merged image. + var rest = options.Clone(); + rest.Rotation = ImageRotation.None; + rest.Flip = ImageFlip.None; + ImageEncoder.ApplyOptions(merged, rest, format); + cancellationToken.ThrowIfCancellationRequested(); + ImageEncoder.WriteImage(merged, outputPath, format); + if (options.MaxSizeMb > 0) + ImageEncoder.FitToSize(merged, outputPath, ImageEncoder.OutputFormat(format, rest).Format, SizeLimit.Bytes(options.MaxSizeMb)); + progress?.Report(1); + } + finally + { + foreach (var picture in kept) + picture?.Dispose(); + } + } + + /// + /// One picture as it looks: the first page or frame (an icon's largest size), turned by its EXIF orientation and the + /// preset, in sRGB so photos with different colour profiles match. + /// + private static IMagickImage Read((string Path, string Format) input, Preset reading, ImageOptions options) + { + using var images = ImageReader.Read(input.Path, input.Format, reading); + if (images.Count == 0) + throw new InvalidOperationException("The picture has no image in it."); + var picture = ImageEncoder.SelectFrames(images, input.Format, "png")[0].Clone(); + try + { + picture.AutoOrient(); + ImageEncoder.Turn(picture, options); + if (picture.GetColorProfile() is { } profile + && profile.Description?.Contains("sRGB", StringComparison.OrdinalIgnoreCase) != true) + picture.TransformColorSpace(ColorProfiles.SRGB); + else if (picture.ColorSpace == ColorSpace.CMYK) + picture.ColorSpace = ColorSpace.sRGB; + picture.ResetPage(); + return picture; + } + catch + { + picture.Dispose(); + throw; + } + } + + /// The background colour: #RRGGBB, , white for anything else. + internal static MagickColor Background(string? colour) + { + if (string.Equals(colour, ImageOptions.TransparentJoin, StringComparison.OrdinalIgnoreCase)) + return MagickColors.Transparent; + try + { + return new MagickColor(string.IsNullOrWhiteSpace(colour) ? "#FFFFFF" : colour.Trim()); + } + catch (ArgumentException) + { + return MagickColors.White; + } + } +} diff --git a/src/Filee.Engines/Magick/MagickImageConverter.cs b/src/Filee.Engines/Magick/MagickImageConverter.cs index 2700afdbd..dc079a786 100644 --- a/src/Filee.Engines/Magick/MagickImageConverter.cs +++ b/src/Filee.Engines/Magick/MagickImageConverter.cs @@ -11,7 +11,7 @@ namespace Filee.Engines.Magick; /// DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster and MIFF, and reads HEIC, XCF, camera RAW, DICOM, /// Photo CD, OpenEXR, Radiance HDR, FITS and (on Windows) EMF/WMF. /// -public sealed class MagickImageConverter : IConverter, ITiffMerger, IImageTransformer +public sealed class MagickImageConverter : IConverter, ITiffMerger, IImageTransformer, IImageJoiner { public string Id => "magick"; public string DisplayName => "ImageMagick"; @@ -66,6 +66,15 @@ public Task MergeAsync(IReadOnlyList inputPaths, string outputPath, File pages.Write(outputPath, MagickFormat.Tiff); }, cancellationToken); + public bool CanRead(string format) => ImageEncoder.Sources.Contains(format); + + public bool CanWrite(string format) => ImageEncoder.Writable.Contains(format); + + /// "Merge images": the pictures one under another or side by side in one image (). + public Task JoinAsync(IReadOnlyList<(string Path, string Format)> inputs, string outputPath, string format, + Filee.Core.Presets.ImageOptions options, IProgress? progress, CancellationToken cancellationToken) => + Task.Run(() => ImageJoiner.Join(inputs, outputPath, format, options, progress, cancellationToken), cancellationToken); + public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => Task.Run>(() => { diff --git a/tests/Filee.App.Tests/MergeOrderTests.cs b/tests/Filee.App.Tests/MergeOrderTests.cs index 751005cb1..080193d87 100644 --- a/tests/Filee.App.Tests/MergeOrderTests.cs +++ b/tests/Filee.App.Tests/MergeOrderTests.cs @@ -1,4 +1,5 @@ -// Merge order (issue #268): the real chooser opened from the donut, then the pages of the merged PDF. +// Merge order (issue #268): the real chooser opened from the donut, then the pages of the merged PDF; for "Merge images" +// (#645) the layout below the list, the merged image and the choices remembered. using Avalonia; using Avalonia.Controls; @@ -10,7 +11,9 @@ using Filee.App.ViewModels; using Filee.App.Views; using Filee.Core.Conversion; +using Filee.Core.Localization; using Filee.Core.Settings; +using ImageMagick; using PdfSharp.Pdf; using PdfSharp.Pdf.IO; @@ -166,6 +169,238 @@ Rect Box(string name) finally { window.Close(); } } + private static void ClickControl(Window window, string name) + { + var control = window.FindControl(name)!; + var at = control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)!.Value; + window.MouseDown(at, MouseButton.Left); + window.MouseUp(at, MouseButton.Left); + RenderTests.Pump(); + } + + private static string Picture(string folder, string name, MagickColor colour, uint width, uint height) + { + using var image = new MagickImage(colour, width, height); + var path = Path.Combine(folder, name); + image.Write(path, MagickFormat.Png); + return path; + } + + /// + /// "Merge images" (#645): the same chooser, with the layout below the list. The choices go into the merge and are + /// remembered for the next one; Cancel starts nothing and remembers nothing. + /// + [AvaloniaTheory] + [InlineData(false)] + [InlineData(true)] + public async Task Donut_merge_of_pictures_takes_the_layout_chosen_and_remembers_it(bool cancel) + { + TestServices.EnsureInitialized(); + var folder = Directory.CreateTempSubdirectory("filee-merge-images-").FullName; + var store = AppHost.Get(); + var profile = store.Profiles.Single(p => p.Id == "images"); + var previous = profile.PresetIds; + var preset = store.FindPreset("merge-images")!; + var (previousOutput, previousImage) = (preset.Output, preset.Image.Clone()); + var conversions = AppHost.Get(); + var before = conversions.Jobs.Select(j => j.Job.Id).ToHashSet(); + var radial = AppHost.Get(); + var windows = AppHost.Get(); + try + { + var wide = Picture(folder, "1 front.png", MagickColors.Red, 100, 40); + var tall = Picture(folder, "2 back.png", MagickColors.Blue, 50, 80); + profile.PresetIds = ["merge-images"]; + preset.Output = new() + { + Location = Filee.Core.Presets.OutputLocation.CustomFolder, + CustomFolder = folder, + FileNamePattern = previousOutput.FileNamePattern, + }; + radial.ShowForFiles(450, 400, [tall, wide]); + var donut = radial.Window!.DonutControl; + Assert.True(donut.HandleKey(Key.Right)); + Assert.True(donut.HandleKey(Key.Enter)); + RenderTests.Pump(); + + var dialog = Assert.IsType(windows.MergeOrder); + var vm = Assert.IsType(dialog.DataContext); + Assert.True(vm.MergesImages); + Assert.True(dialog.FindControl("MergeLayout")!.IsEffectivelyVisible); + Assert.Equal("Merge images", dialog.StartMerge.Content); + Assert.Equal([wide, tall], vm.OrderedFiles); + // As the preset says: one under another, the same width, no gap, white. + Assert.True(dialog.FindControl("TopToBottom")!.IsChecked); + Assert.True(dialog.FindControl("SameWidth")!.IsEffectivelyVisible); + Assert.False(dialog.FindControl("SameHeight")!.IsEffectivelyVisible); + + ClickControl(dialog, "SideBySide"); + Assert.True(vm.SideBySide); + Assert.False(dialog.FindControl("TopToBottom")!.IsChecked); + Assert.True(dialog.FindControl("SameHeight")!.IsEffectivelyVisible); + ClickControl(dialog, "SameHeight"); // keep their sizes, centred + Assert.False(vm.SameSize); + dialog.FindControl("GapBox")!.Value = 6; + RenderTests.Pump(); + Assert.Equal(6, vm.Gap); + ClickControl(dialog, "Transparent"); + Assert.True(vm.Transparent); + Assert.False(dialog.FindControl("BackgroundPicker")!.IsEffectivelyEnabled); + ClickControl(dialog, cancel ? "CancelMerge" : "StartMerge"); + + var saved = store.FindPreset("merge-images")!.Image; + if (cancel) + { + await Task.Delay(50, TestContext.Current.CancellationToken); + Dispatcher.UIThread.RunJobs(); + Assert.DoesNotContain(conversions.Jobs, j => !before.Contains(j.Job.Id)); + Assert.Equal(Filee.Core.Presets.ImageJoinDirection.Vertical, saved.JoinDirection); + Assert.Equal("#FFFFFF", saved.JoinBackground); + return; + } + + JobViewModel? started = null; + for (var i = 0; i < 300; i++) + { + Dispatcher.UIThread.RunJobs(); + started = conversions.Jobs.FirstOrDefault(j => !before.Contains(j.Job.Id)); + if (started?.Job.State is JobState.Completed or JobState.Failed) break; + await Task.Delay(20, TestContext.Current.CancellationToken); + } + Assert.NotNull(started); + Assert.Equal(JobState.Completed, started.Job.State); + var output = Assert.Single(started.Job.Outputs); + Assert.Equal("1 front_merged.png", Path.GetFileName(output)); + using (var merged = new MagickImage(output)) + { + // Side by side at their own sizes, 6 px apart; the low one centred on a see-through background. + Assert.Equal((156u, 80u), (merged.Width, merged.Height)); + using var pixels = merged.GetPixels(); + Assert.Equal(0, pixels.GetPixel(50, 5).ToColor()!.A); + Assert.Equal(MagickColors.Red, pixels.GetPixel(50, 40).ToColor()); + Assert.Equal(MagickColors.Blue, pixels.GetPixel(130, 5).ToColor()); + } + + // Remembered in the library, also on disk, for the next merge. + Assert.Equal((Filee.Core.Presets.ImageJoinDirection.Horizontal, Filee.Core.Presets.ImageJoinFit.Original, 6, "transparent"), + (saved.JoinDirection, saved.JoinFit, saved.JoinGap, saved.JoinBackground)); + var reloaded = new UserDataStore(store.Directory); + reloaded.Load(); + Assert.Equal(Filee.Core.Presets.ImageJoinDirection.Horizontal, reloaded.FindPreset("merge-images")!.Image.JoinDirection); + Assert.True(new MergeOrderViewModel([wide, tall], saved).SideBySide); + } + finally + { + windows.MergeOrder?.Close(); + radial.Close(animate: false); + profile.PresetIds = previous; + preset.Output = previousOutput; + preset.Image = previousImage; + store.SaveLibrary(); + Directory.Delete(folder, true); + } + } + + public static TheoryData MergeImagesLooks => new() + { + { "en", false }, { "en", true }, { "de", false }, { "de", true }, { "ar", false }, { "ar", true }, { "ko", false }, { "ko", true }, + { "ru", false }, { "hi", true }, { "ne", false }, { "th", true }, + }; + + /// + /// The layout of "Merge images" at the smallest size the window opens with for it: every choice whole and inside + /// the window in long languages, mirrored in Arabic, in light and dark. + /// + [AvaloniaTheory] + [MemberData(nameof(MergeImagesLooks))] + public void Merge_images_layout_fits_at_its_minimum_size(string language, bool dark) + { + TestServices.EnsureInitialized(language); + var theme = AppHost.Get(); + theme.Apply(new ThemeSettings { Mode = dark ? ThemeMode.Dark : ThemeMode.Light }, language); + var preset = Filee.Core.Presets.BuiltInData.CreatePresets().Single(p => p.Id == "merge-images"); + var vm = new MergeOrderViewModel([ + @"C:\Users\me\Pictures\Passport front.jpg", + @"C:\Users\me\Pictures\Passport back.jpg", + @"C:\Users\me\Downloads\Driving licence, both sides, scanned at 600 dpi.png"], preset.Image); + var window = new MergeOrderWindow { DataContext = vm, Width = 440, Height = 540 }; + window.StartMerge.Content = AppHost.Get().DisplayName(preset); + try + { + window.Show(); + RenderTests.Pump(); + Rect Box(string name) + { + var control = window.FindControl(name)!; + return new Rect(control.Bounds.Size).TransformToAABB(control.TransformToVisual(window)!.Value); + } + var layout = Box("MergeLayout"); + string[] choices = ["TopToBottom", "SideBySide", "SameWidth", "GapBox", "BackgroundPicker", "BackgroundHex", "Transparent"]; + var boxes = choices.ToDictionary(name => name, Box); + foreach (var (name, box) in boxes) + { + Assert.True(box.Width > 0 && box.Left >= layout.Left - 0.5 && box.Right <= layout.Right + 0.5 && box.Bottom <= layout.Bottom + 0.5, + $"{name} {box} outside {layout}"); + foreach (var (other, otherBox) in boxes.Where(b => b.Key != name)) + Assert.False(box.Deflate(1).Intersects(otherBox.Deflate(1)), $"{name} {box} overlaps {other} {otherBox}"); + } + // The checkbox text is shown whole (not cut at the window's edge). + var sameWidth = window.FindControl("SameWidth")!; + Assert.True(sameWidth.Bounds.Width >= sameWidth.DesiredSize.Width - 0.5); + // Arabic mirrors the switch: "top to bottom" comes first, on the right. + if (language == "ar") + Assert.True(boxes["TopToBottom"].Left > boxes["SideBySide"].Left); + else + Assert.True(boxes["TopToBottom"].Right <= boxes["SideBySide"].Left + 0.5); + Assert.True(window.FindControl("FileOrder")!.Bounds.Height > 60); + Assert.True(Box("StartMerge").Right <= window.Bounds.Width && Box("StartMerge").Left >= 0); + RenderTests.Save(window, $"merge-images-{language}-{(dark ? "dark" : "light")}.png"); + + // The other choices: side by side, see-through. + vm.SideBySide = true; + vm.Transparent = true; + RenderTests.Pump(); + Assert.True(window.FindControl("SameHeight")!.IsEffectivelyVisible); + RenderTests.Save(window, $"merge-images-{language}-{(dark ? "dark" : "light")}-side-by-side.png"); + } + finally + { + window.Close(); + theme.Apply(new ThemeSettings(), language); + } + } + + [Fact] + public void The_layout_chosen_is_written_into_the_merge_and_other_merges_show_none() + { + var image = new Filee.Core.Presets.ImageOptions { JoinGap = 30, JoinBackground = "#102030" }; + var vm = new MergeOrderViewModel(["a.png", "b.png"], image); + Assert.True(vm.MergesImages); + Assert.Equal((false, true, 30, Avalonia.Media.Color.Parse("#102030")), (vm.SideBySide, vm.SameSize, vm.Gap, vm.BackgroundColor)); + + vm.TopToBottom = false; + vm.SameSize = false; + vm.Gap = 5000; + vm.BackgroundColor = Avalonia.Media.Colors.Black; + vm.ApplyTo(image); + Assert.Equal((Filee.Core.Presets.ImageJoinDirection.Horizontal, Filee.Core.Presets.ImageJoinFit.Original, Filee.Core.Presets.ImageOptions.MaxJoinGap, "#000000"), + (image.JoinDirection, image.JoinFit, image.JoinGap, image.JoinBackground)); + vm.BackgroundHex = "#336699"; + Assert.Equal(Avalonia.Media.Color.Parse("#336699"), vm.BackgroundColor); + vm.ApplyTo(image); + Assert.Equal("#336699", image.JoinBackground); + vm.BackgroundColor = Avalonia.Media.Colors.Red; + Assert.Equal("#FF0000", vm.BackgroundHex); + vm.Transparent = true; + Assert.False(vm.PicksColor); + vm.ApplyTo(image); + Assert.Equal("transparent", image.JoinBackground); + + Assert.False(new MergeOrderViewModel(["a.pdf", "b.pdf"]).MergesImages); + // A colour that isn't one (a hand-edited file) shows white. + Assert.Equal(Avalonia.Media.Colors.White, new MergeOrderViewModel(["a.png"], new() { JoinBackground = "blue-ish" }).BackgroundColor); + } + [Fact] public void Files_start_in_name_order_counting_numbers_and_browser_files_show_no_folder() { diff --git a/tests/Filee.Core.Tests/MergeImagesTests.cs b/tests/Filee.Core.Tests/MergeImagesTests.cs new file mode 100644 index 000000000..265d8c11a --- /dev/null +++ b/tests/Filee.Core.Tests/MergeImagesTests.cs @@ -0,0 +1,277 @@ +// "Merge images" (#645): the preset, its place on the images donut, how existing libraries get it (settings schema 53), +// presets files older and newer Filee versions read, and the queue merging pictures into one image. + +using System.Text.Json; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class MergeImagesTests +{ + private static Preset BuiltIn(string id) => BuiltInData.CreatePresets().Single(p => p.Id == id); + + /// The images donut's back face of settings schema 37 to 52. + private static readonly string[] BackBefore53 = + ["tool-crop", "rotate", "flip", "remove-background", "trim-transparent", "grayscale", "to-avif", "to-gif", "merge-pdf", + "compress-image", "remove-metadata", "zip-all"]; + + [Fact] + public void A_new_library_merges_pictures_in_their_first_format_next_to_merge_pdf() + { + var merge = BuiltIn("merge-images"); + Assert.Equal("preset.merge_images", merge.NameKey); + Assert.True(merge.JoinsImages); + Assert.False(merge.JoinsIntoOneMedia); + Assert.Equal(BuiltInData.SameAsSource, merge.TargetFormat); + Assert.Equal("{name}_merged", merge.Output.FileNamePattern); // as "Merge videos": named after the first file + Assert.Equal((ImageJoinDirection.Vertical, ImageJoinFit.SameSize, 0, "#FFFFFF"), + (merge.Image.JoinDirection, merge.Image.JoinFit, merge.Image.JoinGap, merge.Image.JoinBackground)); + + var back = BuiltInData.CreateProfiles().Single(p => p.Id == "images").BackPresetIds!; + Assert.Equal(ToolbarProfile.MaxSlices, back.Count); + Assert.Equal(["to-avif", "merge-images", "merge-pdf"], back[6..9]); + // GIF gave its slot: still a preset of the library, and on the video donut's front. + Assert.DoesNotContain("to-gif", back); + Assert.Single(BuiltInData.CreatePresets(), p => p.Id == "to-gif"); + Assert.Equal(["merge-images"], BuiltInData.CreatePresets().Where(p => p.JoinsImages).Select(p => p.Id)); + } + + [Theory] + [InlineData("*", true)] + [InlineData("png", true)] + [InlineData("jpg", true)] + [InlineData("pdf", false)] + [InlineData("mp4", false)] + public void Only_a_picture_target_merges_pictures(string target, bool merges) + { + Assert.Equal(merges, new Preset { TargetFormat = target, Image = { JoinIntoOne = true } }.JoinsImages); + Assert.False(new Preset { TargetFormat = target }.JoinsImages); + } + + [Fact] + public void A_merge_of_pictures_is_saved_as_one_file() + { + Assert.True(AskedLocation.MakesOneResult(BuiltIn("merge-images"), 3)); + Assert.False(AskedLocation.MakesOneResult(BuiltIn("to-png"), 3)); + } + + [Fact] + public void The_gap_stays_within_its_limits() + { + Assert.Equal(0, new ImageOptions { JoinGap = -5 }.JoinSpacing); + Assert.Equal(12, new ImageOptions { JoinGap = 12 }.JoinSpacing); + Assert.Equal(ImageOptions.MaxJoinGap, new ImageOptions { JoinGap = 1_000_000 }.JoinSpacing); + } + + [Fact] + public void Schema_53_puts_merge_images_in_place_of_gif_on_a_full_images_back_face() + { + var presets = BuiltInData.CreatePresets().Where(p => p.Id != "merge-images").ToList(); + var profiles = BuiltInData.CreateProfiles(); + var images = profiles.Single(p => p.Id == "images"); + images.BackPresetIds = [.. BackBefore53]; + + Assert.True(SettingsMigrations.ApplyToLibrary(50, presets, profiles)); + + Assert.Equal(BuiltInData.CreateProfiles().Single(p => p.Id == "images").BackPresetIds, images.BackPresetIds); + // In the library after the media tools, as in a new one. + Assert.Equal(presets.FindIndex(p => p.Id == "remove-metadata") + 1, presets.FindIndex(p => p.Id == "merge-images")); + Assert.Single(presets, p => p.Id == "to-gif"); + Assert.False(SettingsMigrations.ApplyToLibrary(AppSettings.CurrentSchemaVersion, presets, profiles)); + } + + [Fact] + public void Schema_53_takes_a_free_slot_before_merge_pdf_and_leaves_gif() + { + var presets = BuiltInData.CreatePresets().Where(p => p.Id != "merge-images").ToList(); + var profiles = BuiltInData.CreateProfiles(); + var images = profiles.Single(p => p.Id == "images"); + images.BackPresetIds = ["tool-crop", "to-gif", "merge-pdf", "zip-all"]; + + Assert.True(SettingsMigrations.ApplyToLibrary(50, presets, profiles)); + + Assert.Equal(["tool-crop", "to-gif", "merge-images", "merge-pdf", "zip-all"], images.BackPresetIds); + } + + [Fact] + public void Schema_53_leaves_a_donut_where_the_user_put_merge_images_or_has_no_gif_to_give() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var images = profiles.Single(p => p.Id == "images"); + images.PresetIds = ["merge-images", "to-png"]; + images.BackPresetIds = [.. BackBefore53]; + + Assert.False(SettingsMigrations.ApplyToLibrary(50, presets, profiles)); + Assert.Equal(BackBefore53, images.BackPresetIds); + + // A full face the user laid out without GIF: nothing of theirs goes. + images.PresetIds = ["to-png"]; + images.BackPresetIds = [.. BackBefore53.Select(id => id == "to-gif" ? "to-bmp" : id)]; + Assert.False(SettingsMigrations.ApplyToLibrary(50, presets, profiles)); + Assert.DoesNotContain("merge-images", images.BackPresetIds); + } + + [Fact] + public void Schema_53_puts_the_option_back_when_an_older_filee_dropped_it() + { + // A Filee before 53 saved the library again: the preset is left with what it knows, its name kept. + var presets = BuiltInData.CreatePresets(); + var stripped = presets.Single(p => p.Id == "merge-images"); + stripped.Image.JoinIntoOne = false; + stripped.Name = "Stack my scans"; + var profiles = BuiltInData.CreateProfiles(); + + Assert.True(SettingsMigrations.ApplyToLibrary(50, presets, profiles)); + + Assert.True(stripped.JoinsImages); + Assert.Equal("Stack my scans", stripped.Name); + Assert.Single(presets, p => p.Id == "merge-images"); + } + + [Fact] + public void A_presets_file_keeps_the_layout_and_reads_a_newer_filee_s_values_as_the_defaults() + { + var merge = BuiltIn("merge-images"); + merge.Image.JoinDirection = ImageJoinDirection.Horizontal; + merge.Image.JoinFit = ImageJoinFit.Original; + merge.Image.JoinGap = 24; + merge.Image.JoinBackground = ImageOptions.TransparentJoin; + var json = JsonSerializer.Serialize([merge], FileeJsonContext.Default.ListPreset); + Assert.Contains("\"joinDirection\": \"Horizontal\"", json); // by name, as the other enums + var read = Assert.Single(JsonSerializer.Deserialize(json, FileeJsonContext.Default.ListPreset)!); + Assert.Equal((ImageJoinDirection.Horizontal, ImageJoinFit.Original, 24, "transparent"), + (read.Image.JoinDirection, read.Image.JoinFit, read.Image.JoinGap, read.Image.JoinBackground)); + + // A direction or fit this version doesn't know (a newer Filee's) doesn't lose the presets file. + var newer = json.Replace("\"Horizontal\"", "\"Diagonal\"").Replace("\"Original\"", "7"); + var fallback = Assert.Single(JsonSerializer.Deserialize(newer, FileeJsonContext.Default.ListPreset)!); + Assert.Equal((ImageJoinDirection.Vertical, ImageJoinFit.SameSize), (fallback.Image.JoinDirection, fallback.Image.JoinFit)); + } + + private static async Task Run(JobQueue queue, IReadOnlyList sources, Preset preset) + { + var job = new ConversionJob(sources, preset, "Merge images"); + await queue.RunAsync(job); + return job; + } + + [Fact] + public async Task Pictures_merge_in_order_into_one_file_in_the_first_one_s_format() + { + using var dir = new TempDir(); + var second = dir.File("b.png"); + var first = dir.File("a.jpg"); + var joiner = new FakeImageJoiner(); + await using var queue = new JobQueue(new ConverterCatalog([joiner])); + + var job = await Run(queue, [first, second], BuiltIn("merge-images")); + + Assert.Equal(JobState.Completed, job.State); + var output = Assert.Single(job.Outputs); + Assert.Equal("a_merged.jpg", Path.GetFileName(output)); + Assert.Equal([(first, "jpg"), (second, "png")], joiner.Inputs); + Assert.Equal("jpg", joiner.Format); + Assert.All(job.Files, f => Assert.Equal(FileState.Done, f.State)); + Assert.Equal("a.jpg|b.png", await File.ReadAllTextAsync(output, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task A_picture_the_joiner_cannot_read_becomes_a_plain_png_and_a_first_format_it_cannot_write_gives_png() + { + using var dir = new TempDir(); + var icon = dir.File("a.icns"); + var photo = dir.File("b.jpg"); + var document = dir.File("c.docx"); + var joiner = new FakeImageJoiner { Unreadable = ["icns"], Unwritable = ["icns"] }; + var engine = new FakeConverter("icns", new ConversionEdge("icns", "png")); + await using var queue = new JobQueue(new ConverterCatalog([engine, joiner])); + var preset = BuiltIn("merge-images").Clone(); + preset.Image.Resize = ResizeMode.Percent; // the merged image's: not applied to the picture on its way + preset.Image.Rotation = ImageRotation.Right90; + + var job = await Run(queue, [icon, photo, document], preset); + + Assert.Equal(JobState.CompletedWithErrors, job.State); + Assert.Equal("error.images_only", job.Files[2].ErrorKey); + Assert.Equal("a_merged.png", Path.GetFileName(Assert.Single(job.Outputs))); + Assert.Equal("png", joiner.Format); + Assert.Equal(2, joiner.Inputs.Count); + Assert.Equal("png", joiner.Inputs[0].Format); + Assert.StartsWith(Path.Combine(Path.GetTempPath(), "Filee"), joiner.Inputs[0].Path); // in the job's work folder + Assert.Equal((photo, "jpg"), joiner.Inputs[1]); + var step = Assert.Single(engine.Presets); + Assert.Equal((ResizeMode.None, ImageRotation.None, false), (step.Image.Resize, step.Image.Rotation, step.Image.JoinIntoOne)); + Assert.Equal(ImageRotation.Right90, joiner.Options!.Rotation); // turned once, as the pictures merge + } + + [Fact] + public async Task A_target_the_joiner_cannot_write_fails_every_picture() + { + using var dir = new TempDir(); + var joiner = new FakeImageJoiner { Unwritable = ["heic"] }; + await using var queue = new JobQueue(new ConverterCatalog([joiner])); + var preset = BuiltIn("merge-images").Clone(); + preset.TargetFormat = "heic"; + + var job = await Run(queue, [dir.File("a.png"), dir.File("b.png")], preset); + + Assert.Equal(JobState.Failed, job.State); + Assert.All(job.Files, f => Assert.Equal("error.no_route", f.ErrorKey)); + Assert.Empty(joiner.Inputs); + } + + [Fact] + public async Task A_merge_refused_as_too_large_says_why_and_leaves_no_file() + { + using var dir = new TempDir(); + var joiner = new FakeImageJoiner { Refusal = new ConversionRefusedException("error.merged_image_too_large", "30000 × 9000 px") }; + await using var queue = new JobQueue(new ConverterCatalog([joiner])); + + var job = await Run(queue, [dir.File("a.png"), dir.File("b.png")], BuiltIn("merge-images")); + + Assert.Equal(JobState.Failed, job.State); + Assert.All(job.Files, f => Assert.Equal(("error.merged_image_too_large", "30000 × 9000 px"), (f.ErrorKey, f.ErrorDetail))); + Assert.Empty(job.Outputs); + Assert.False(File.Exists(Path.Combine(dir.Path, "a_merged.png"))); + } +} + +/// Test double for the image engine's merging: records what it was given and writes the pictures' names. +internal sealed class FakeImageJoiner : IConverter, IImageJoiner +{ + public string Id => "fake-joiner"; + public string DisplayName => Id; + public IReadOnlyList Edges => []; + public int MaxParallelism => 0; + public EngineStatus GetStatus() => EngineStatus.Available(); + + public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => + throw new NotSupportedException(); + + public HashSet Unreadable { get; init; } = []; + public HashSet Unwritable { get; init; } = []; + public Exception? Refusal { get; init; } + public List<(string Path, string Format)> Inputs { get; } = []; + public string? Format { get; private set; } + public ImageOptions? Options { get; private set; } + + public bool CanRead(string format) => !Unreadable.Contains(format); + public bool CanWrite(string format) => !Unwritable.Contains(format); + + public async Task JoinAsync(IReadOnlyList<(string Path, string Format)> inputs, string outputPath, string format, ImageOptions options, + IProgress? progress, CancellationToken cancellationToken) + { + // Something written first, as a real merge would: a refused one must not leave it behind. + await File.WriteAllTextAsync(outputPath, "", cancellationToken); + if (Refusal is not null) + throw Refusal; + Inputs.AddRange(inputs); + Format = format; + Options = options; + await File.WriteAllTextAsync(outputPath, string.Join("|", inputs.Select(i => Path.GetFileName(i.Path))), cancellationToken); + } +} diff --git a/tests/Filee.Engines.Tests/MergeImagesEngineTests.cs b/tests/Filee.Engines.Tests/MergeImagesEngineTests.cs new file mode 100644 index 000000000..9f751e7d9 --- /dev/null +++ b/tests/Filee.Engines.Tests/MergeImagesEngineTests.cs @@ -0,0 +1,229 @@ +// "Merge images" (#645) with ImageMagick: generated pictures merged one under another and side by side, with a gap, +// pictures of different sizes, a background colour or transparency, and the limit on the merged image's size. + +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Magick; +using ImageMagick; + +namespace Filee.Engines.Tests; + +public class MergeImagesEngineTests(EngineFixture fx) : IClassFixture +{ + private static Preset Merge(Action? set = null) + { + var preset = Filee.Core.Presets.BuiltInData.CreatePresets().Single(p => p.Id == "merge-images").Clone(); + set?.Invoke(preset.Image); + return preset; + } + + private static string Picture(string folder, string name, MagickColor colour, uint width, uint height, MagickFormat format = MagickFormat.Png) + { + using var image = new MagickImage(colour, width, height); + var path = Path.Combine(folder, name); + image.Write(path, format); + return path; + } + + private static string Output(ConversionJob job) + { + var errors = string.Join("; ", job.Files.Where(f => f.State == FileState.Failed).Select(f => $"{f.ErrorKey} {f.ErrorDetail}")); + Assert.True(job.State == JobState.Completed, $"{job.State}: {errors}"); + return Assert.Single(job.Outputs); + } + + private static IMagickColor Colour(IMagickImage image, int x, int y) + { + using var pixels = image.GetPixels(); + return pixels.GetPixel(x, y).ToColor()!; + } + + /// The colour at (x, y) as #RRGGBBAA. + private static string At(IMagickImage image, int x, int y) + { + var colour = Colour(image, x, y); + return $"#{colour.R:X2}{colour.G:X2}{colour.B:X2}{(image.HasAlpha ? colour.A : 255):X2}"; + } + + [Fact] + public async Task Pictures_of_different_widths_stack_at_the_same_width_with_a_white_gap() + { + var dir = fx.NewFolder(); + var wide = Picture(dir, "1 front.png", MagickColors.Red, 200, 100); + var narrow = Picture(dir, "2 back.png", MagickColors.Blue, 100, 100); + + var output = Output(await fx.ConvertAsync([wide, narrow], Merge(i => i.JoinGap = 10))); + + Assert.Equal("1 front_merged.png", Path.GetFileName(output)); + using var merged = new MagickImage(output); + // The narrow one is scaled up to the width of the wide one: 100 + 10 + 200. + Assert.Equal((200u, 310u), (merged.Width, merged.Height)); + Assert.Equal("#FF0000FF", At(merged, 100, 50)); + Assert.Equal("#FFFFFFFF", At(merged, 100, 105)); + Assert.Equal("#0000FFFF", At(merged, 100, 210)); + Assert.Equal("#0000FFFF", At(merged, 199, 309)); + } + + [Fact] + public async Task Side_by_side_pictures_keep_their_sizes_centred_on_a_transparent_background() + { + var dir = fx.NewFolder(); + var low = Picture(dir, "a.png", MagickColors.Red, 100, 50); + var tall = Picture(dir, "b.png", MagickColors.Lime, 60, 100); + + var output = Output(await fx.ConvertAsync([low, tall], Merge(i => + { + i.JoinDirection = ImageJoinDirection.Horizontal; + i.JoinFit = ImageJoinFit.Original; + i.JoinGap = 8; + i.JoinBackground = ImageOptions.TransparentJoin; + }))); + + using var merged = new MagickImage(output); + Assert.Equal((168u, 100u), (merged.Width, merged.Height)); + Assert.EndsWith("00", At(merged, 50, 10)); // above the low picture: see-through + Assert.Equal("#FF0000FF", At(merged, 50, 50)); // centred: rows 25 to 74 + Assert.EndsWith("00", At(merged, 50, 80)); + Assert.EndsWith("00", At(merged, 104, 50)); // the gap + Assert.Equal("#00FF00FF", At(merged, 140, 5)); + } + + [Fact] + public async Task Side_by_side_pictures_of_the_same_height_into_a_jpg_get_a_coloured_gap() + { + var dir = fx.NewFolder(); + var photo = Picture(dir, "card front.jpg", MagickColors.White, 300, 200, MagickFormat.Jpeg); + var gray = Path.Combine(dir, "card back.png"); + using (var image = new MagickImage(new MagickColor("#808080"), 150, 50)) + { + image.ColorType = ColorType.Grayscale; // a greyscale picture joins a colour one + image.Write(gray, MagickFormat.Png); + } + + var output = Output(await fx.ConvertAsync([photo, gray], Merge(i => + { + i.JoinDirection = ImageJoinDirection.Horizontal; + i.JoinGap = 20; + i.JoinBackground = "#000080"; + }))); + + Assert.Equal("card front_merged.jpg", Path.GetFileName(output)); // the first picture's format + using var merged = new MagickImage(output); + Assert.Equal(MagickFormat.Jpeg, merged.Format); + // The greyscale one scaled to the height of the photo: 150 × 50 → 600 × 200. + Assert.Equal((300u + 20 + 600, 200u), (merged.Width, merged.Height)); + var gap = Colour(merged, 310, 100); + Assert.InRange(gap.B, 100, 150); // navy, give or take JPEG + Assert.InRange(gap.R, 0, 30); + var back = Colour(merged, 600, 100); + Assert.InRange(back.R, 115, 140); + Assert.InRange(back.B, 115, 140); + } + + [Fact] + public async Task Each_picture_turns_before_the_merge_and_a_transparent_gap_turns_white_in_a_jpg() + { + var dir = fx.NewFolder(); + var first = Picture(dir, "a.jpg", MagickColors.Red, 200, 100, MagickFormat.Jpeg); + var second = Picture(dir, "b.jpg", MagickColors.Blue, 200, 100, MagickFormat.Jpeg); + + var output = Output(await fx.ConvertAsync([first, second], Merge(i => + { + i.Rotation = ImageRotation.Right90; + i.JoinDirection = ImageJoinDirection.Horizontal; + i.JoinGap = 10; + i.JoinBackground = ImageOptions.TransparentJoin; + }))); + + using var merged = new MagickImage(output); + // Two 100 × 200 pictures, not one 200 × 210 picture turned on its side. + Assert.Equal((210u, 200u), (merged.Width, merged.Height)); + var gap = Colour(merged, 105, 100); + Assert.True(gap.R > 240 && gap.G > 240 && gap.B > 240, $"{gap}"); + } + + [Fact] + public async Task A_drawing_becomes_a_picture_first_and_merges_with_a_photo() + { + var dir = fx.NewFolder(); + var svg = Path.Combine(dir, "logo.svg"); + await File.WriteAllTextAsync(svg, + """""", + TestContext.Current.CancellationToken); + var photo = Picture(dir, "photo.png", MagickColors.Red, 100, 100); + + var output = Output(await fx.ConvertAsync([svg, photo], Merge())); + + // Named after the drawing, written as PNG: a merged picture can't be an SVG. + Assert.Equal("logo_merged.png", Path.GetFileName(output)); + using var merged = new MagickImage(output); + // The drawing is rendered at the vector engine's size, the photo scaled to its width. + var width = (int)merged.Width; + Assert.Equal(width / 2 + width, (int)merged.Height); + Assert.Equal("#00FF00FF", At(merged, width / 2, width / 4)); + Assert.Equal("#FF0000FF", At(merged, width / 2, width)); + } + + [Fact] + public async Task The_preset_s_resize_applies_to_the_merged_image() + { + var dir = fx.NewFolder(); + var a = Picture(dir, "a.png", MagickColors.Red, 200, 100); + var b = Picture(dir, "b.png", MagickColors.Blue, 200, 100); + + var output = Output(await fx.ConvertAsync([a, b], Merge(i => + { + i.Resize = ResizeMode.Percent; + i.ResizePercent = 50; + }))); + + using var merged = new MagickImage(output); + Assert.Equal((100u, 100u), (merged.Width, merged.Height)); + } + + [Fact] + public void A_merged_image_beyond_the_limit_is_refused_with_its_size() + { + // 12 photos of 24 MP side by side: 288 MP. + var sizes = Enumerable.Repeat((6000u, 4000u), 12).ToList(); + var refused = Assert.Throws(() => + ImageJoiner.Plan(sizes, new ImageOptions { JoinDirection = ImageJoinDirection.Horizontal })); + Assert.Equal("error.merged_image_too_large", refused.ErrorKey); + Assert.Equal("72000 × 4000 px", refused.Detail); + + // Eight of them fit. + var layout = ImageJoiner.Plan(sizes.Take(8).ToList(), new ImageOptions { JoinDirection = ImageJoinDirection.Horizontal }); + Assert.Equal((48000u, 4000u), (layout.Width, layout.Height)); + Assert.True((long)layout.Width * layout.Height <= ImageJoiner.MaxPixels); + } + + [Fact] + public void The_layout_scales_to_the_widest_or_centres_the_original_sizes() + { + (uint, uint)[] sizes = [(400, 300), (200, 200), (100, 50)]; + + var same = ImageJoiner.Plan(sizes, new ImageOptions { JoinGap = 5 }); + Assert.Equal((400u, 300u + 5 + 400 + 5 + 200), (same.Width, same.Height)); + Assert.Equal([new(0, 0, 400, 300), new(0, 305, 400, 400), new(0, 710, 400, 200)], same.Pictures); + + var kept = ImageJoiner.Plan(sizes, new ImageOptions { JoinFit = ImageJoinFit.Original }); + Assert.Equal((400u, 550u), (kept.Width, kept.Height)); + Assert.Equal([new(0, 0, 400, 300), new(100, 300, 200, 200), new(150, 500, 100, 50)], kept.Pictures); + + var row = ImageJoiner.Plan(sizes, new ImageOptions { JoinDirection = ImageJoinDirection.Horizontal, JoinFit = ImageJoinFit.Original, JoinGap = 2 }); + Assert.Equal((704u, 300u), (row.Width, row.Height)); + Assert.Equal([new(0, 0, 400, 300), new(402, 50, 200, 200), new(604, 125, 100, 50)], row.Pictures); + } + + [Theory] + [InlineData("#00FF00", "#00FF00FF")] + [InlineData("transparent", "#00000000")] + [InlineData("TRANSPARENT", "#00000000")] + [InlineData("not a colour", "#FFFFFFFF")] + [InlineData("", "#FFFFFFFF")] + public void The_background_reads_a_colour_or_transparency_and_falls_back_to_white(string setting, string expected) + { + var colour = ImageJoiner.Background(setting); + Assert.Equal(expected, $"#{colour.R:X2}{colour.G:X2}{colour.B:X2}{colour.A:X2}"); + } +} From bf6f9aad7fb16aa21b82b6cdd3b1cd00d11ac39a Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 28 Oct 2026 03:05:07 +0900 Subject: [PATCH 2/3] feat(images): pixelate or blur parts of a picture before sharing it A "Pixelate / blur" tool on the images donut opens the pictures one at a time in a window drawn like the crop overlay: drag frames over names, faces or numbers, each one showing the picture already pixelated or blurred, move and size them, pick Pixelate or Blur and a strength, then Hide & Save (or Hide & Next). Each picture is written by an ordinary conversion in its own format (PNG when it can't be written), so the toast, the output rules and the history work as usual; the frames carry on to the next picture of a batch. Pixelate averages squares on a grid laid from the picture's corner; blur averages into smaller squares first and then blurs about a line of text wide, so a light, reversible blur is never used. Both are sized from the picture's shorter side. The job queue covers the parts once, in the first step whose engine applies image options, refuses a route that can't, never puts the original back for "only if smaller", and keeps no Retry for them. The previews of the uncovered picture a file carries (EXIF, Photoshop, XMP thumbnails) are dropped. Settings schema 52 adds the tool after Crop and on the images donut after Half size (or on the back face when the front is full). Pixelate or blur and the strength are remembered in the tool, in fields of their own. Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/Assets/i18n/ar.json | 17 + src/Filee.App/Assets/i18n/de.json | 17 + src/Filee.App/Assets/i18n/en.json | 17 + src/Filee.App/Assets/i18n/es.json | 17 + src/Filee.App/Assets/i18n/fr.json | 17 + src/Filee.App/Assets/i18n/hi.json | 17 + src/Filee.App/Assets/i18n/ja.json | 17 + src/Filee.App/Assets/i18n/ko.json | 17 + src/Filee.App/Assets/i18n/ne.json | 17 + src/Filee.App/Assets/i18n/pl.json | 17 + src/Filee.App/Assets/i18n/pt-BR.json | 17 + src/Filee.App/Assets/i18n/pt-PT.json | 17 + src/Filee.App/Assets/i18n/ru.json | 17 + src/Filee.App/Assets/i18n/th.json | 17 + src/Filee.App/Assets/i18n/tr.json | 17 + src/Filee.App/Assets/i18n/zh-CN.json | 17 + src/Filee.App/Assets/i18n/zh-TW.json | 17 + src/Filee.App/Controls/CropCanvas.cs | 51 +- src/Filee.App/Controls/HideAreaSet.cs | 205 ++++++ src/Filee.App/Controls/HideCanvas.cs | 644 ++++++++++++++++++ src/Filee.App/Services/HideService.cs | 93 +++ src/Filee.App/Services/PresetAvailability.cs | 6 + src/Filee.App/Services/WindowService.cs | 40 +- src/Filee.App/Styles/Icons.axaml | 2 + .../ViewModels/CropOverlayViewModel.cs | 10 +- .../ViewModels/HideWindowViewModel.cs | 495 ++++++++++++++ src/Filee.App/Views/HideWindow.axaml | 430 ++++++++++++ src/Filee.App/Views/HideWindow.axaml.cs | 264 +++++++ src/Filee.Core/Conversion/JobQueue.cs | 31 +- src/Filee.Core/History/HistoryEntry.cs | 3 +- src/Filee.Core/Presets/BuiltInData.cs | 17 +- src/Filee.Core/Presets/HideTool.cs | 122 ++++ src/Filee.Core/Presets/Preset.cs | 68 +- src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 54 ++ src/Filee.Engines/Magick/AreaHider.cs | 195 ++++++ src/Filee.Engines/Magick/ImageEncoder.cs | 4 + tests/Filee.App.Tests/HideWindowTests.cs | 464 +++++++++++++ tests/Filee.App.Tests/RenderTests.cs | 2 +- .../ToolbarPaletteViewTests.cs | 7 +- tests/Filee.Core.Tests/BackFaceTests.cs | 4 +- tests/Filee.Core.Tests/HideToolTests.cs | 404 +++++++++++ tests/Filee.Engines.Tests/HideAreasTests.cs | 237 +++++++ 43 files changed, 4093 insertions(+), 50 deletions(-) create mode 100644 src/Filee.App/Controls/HideAreaSet.cs create mode 100644 src/Filee.App/Controls/HideCanvas.cs create mode 100644 src/Filee.App/Services/HideService.cs create mode 100644 src/Filee.App/ViewModels/HideWindowViewModel.cs create mode 100644 src/Filee.App/Views/HideWindow.axaml create mode 100644 src/Filee.App/Views/HideWindow.axaml.cs create mode 100644 src/Filee.Core/Presets/HideTool.cs create mode 100644 src/Filee.Engines/Magick/AreaHider.cs create mode 100644 tests/Filee.App.Tests/HideWindowTests.cs create mode 100644 tests/Filee.Core.Tests/HideToolTests.cs create mode 100644 tests/Filee.Engines.Tests/HideAreasTests.cs diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 8738fcda1..f1631f3bf 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -251,6 +251,23 @@ "crop.state_done": "تم الاقتصاص", "crop.state_skipped": "تم التخطي", "crop.state_failed": "فشل", + "preset.hide": "بكسلة / تمويه", + "preset.hide.donut": "بكسلة", + "error.cannot_hide": "البكسلة والتمويه متاحان للصور فقط", + "hide.save": "إخفاء وحفظ", + "hide.save_next": "إخفاء والتالي", + "hide.pixelate": "بكسلة", + "hide.pixelate_hint": "التغطية بمربعات بمتوسط الألوان (P)", + "hide.blur": "تمويه", + "hide.blur_hint": "التغطية بتمويه قوي (B)", + "hide.strength": "الشدة", + "hide.strength_value": "الشدة {0} من 10 (− / +)", + "hide.remove": "إزالة الإطار", + "hide.remove_hint": "إزالة الإطار المحدد (Del)", + "hide.clear": "إزالة الكل", + "hide.empty_hint": "اسحب فوق ما تريد إخفاءه", + "hide.hint_keys": "يختار Tab إطارًا وتحرّكه مفاتيح الأسهم، ومع Shift تكون الخطوة أكبر بعشر مرات · Del يزيله · Ctrl+Z للتراجع · Esc للإغلاق", + "hide.state_done": "تم", "preset.clip": "قص مقطع", "clip.start": "البداية", "clip.end": "النهاية", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 154a25121..9c5050f0f 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -251,6 +251,23 @@ "crop.state_done": "Zugeschnitten", "crop.state_skipped": "Übersprungen", "crop.state_failed": "Fehlgeschlagen", + "preset.hide": "Verpixeln / Weichzeichnen", + "preset.hide.donut": "Verpixeln", + "error.cannot_hide": "Verpixeln und Weichzeichnen ist nur bei Bildern möglich", + "hide.save": "Verbergen & Speichern", + "hide.save_next": "Verbergen & Weiter", + "hide.pixelate": "Verpixeln", + "hide.pixelate_hint": "Mit Quadraten in der Durchschnittsfarbe abdecken (P)", + "hide.blur": "Weichzeichnen", + "hide.blur_hint": "Mit starker Unschärfe abdecken (B)", + "hide.strength": "Stärke", + "hide.strength_value": "Stärke {0} von 10 (− / +)", + "hide.remove": "Rahmen entfernen", + "hide.remove_hint": "Ausgewählten Rahmen entfernen (Entf)", + "hide.clear": "Alle entfernen", + "hide.empty_hint": "Ziehe über das, was verborgen werden soll", + "hide.hint_keys": "Tab wählt einen Rahmen, Pfeiltasten verschieben ihn, Umschalt × 10 · Entf entfernt ihn · Strg+Z macht rückgängig · Esc schließt", + "hide.state_done": "Fertig", "preset.clip": "Clip schneiden", "clip.start": "Beginn", "clip.end": "Ende", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 4cd5e0eb1..9220cdbc3 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -260,6 +260,23 @@ "crop.state_done": "Cropped", "crop.state_skipped": "Skipped", "crop.state_failed": "Failed", + "preset.hide": "Pixelate / blur", + "preset.hide.donut": "Pixelate", + "error.cannot_hide": "Only pictures can be pixelated or blurred", + "hide.save": "Hide & Save", + "hide.save_next": "Hide & Next", + "hide.pixelate": "Pixelate", + "hide.pixelate_hint": "Cover with squares of the average colour (P)", + "hide.blur": "Blur", + "hide.blur_hint": "Cover with a strong blur (B)", + "hide.strength": "Strength", + "hide.strength_value": "Strength {0} of 10 (− / +)", + "hide.remove": "Remove frame", + "hide.remove_hint": "Remove the selected frame (Del)", + "hide.clear": "Clear all", + "hide.empty_hint": "Drag over what you want to hide", + "hide.hint_keys": "Tab picks a frame, arrow keys move it, Shift × 10 · Del removes it · Ctrl+Z undoes · Esc closes", + "hide.state_done": "Done", "preset.clip": "Cut a clip", "clip.start": "Start", "clip.end": "End", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 02dab483e..d6e636a5f 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -251,6 +251,23 @@ "crop.state_done": "Recortado", "crop.state_skipped": "Omitido", "crop.state_failed": "Error", + "preset.hide": "Pixelar / difuminar", + "preset.hide.donut": "Pixelar", + "error.cannot_hide": "Solo se pueden pixelar o difuminar imágenes", + "hide.save": "Ocultar y guardar", + "hide.save_next": "Ocultar y seguir", + "hide.pixelate": "Pixelar", + "hide.pixelate_hint": "Cubrir con cuadros del color medio (P)", + "hide.blur": "Difuminar", + "hide.blur_hint": "Cubrir con un desenfoque fuerte (B)", + "hide.strength": "Intensidad", + "hide.strength_value": "Intensidad {0} de 10 (− / +)", + "hide.remove": "Quitar marco", + "hide.remove_hint": "Quitar el marco seleccionado (Supr)", + "hide.clear": "Quitar todos", + "hide.empty_hint": "Arrastra sobre lo que quieras ocultar", + "hide.hint_keys": "Tab elige un marco, las flechas lo mueven, Mayús × 10 · Supr lo quita · Ctrl+Z deshace · Esc cierra", + "hide.state_done": "Hecho", "preset.clip": "Cortar un clip", "clip.start": "Inicio", "clip.end": "Fin", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 36e31d0fa..5bf94e50c 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -260,6 +260,23 @@ "crop.state_done": "Recadré", "crop.state_skipped": "Ignoré", "crop.state_failed": "Échec", + "preset.hide": "Pixeliser / flouter", + "preset.hide.donut": "Pixeliser", + "error.cannot_hide": "Seules les images peuvent être pixelisées ou floutées", + "hide.save": "Masquer et enregistrer", + "hide.save_next": "Masquer et suivant", + "hide.pixelate": "Pixeliser", + "hide.pixelate_hint": "Couvrir de carrés de la couleur moyenne (P)", + "hide.blur": "Flouter", + "hide.blur_hint": "Couvrir d'un flou intense (B)", + "hide.strength": "Intensité", + "hide.strength_value": "Intensité {0} sur 10 (− / +)", + "hide.remove": "Supprimer le cadre", + "hide.remove_hint": "Supprimer le cadre sélectionné (Suppr)", + "hide.clear": "Tout effacer", + "hide.empty_hint": "Faites glisser sur ce que vous voulez masquer", + "hide.hint_keys": "Tab choisit un cadre, les flèches le déplacent, Maj × 10 · Suppr le supprime · Ctrl+Z annule · Échap ferme", + "hide.state_done": "Terminé", "preset.clip": "Couper un extrait", "clip.start": "Début", "clip.end": "Fin", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 11dad2416..6d1c00a3c 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -251,6 +251,23 @@ "crop.state_done": "क्रॉप हो गया", "crop.state_skipped": "छोड़ा गया", "crop.state_failed": "विफल", + "preset.hide": "पिक्सलेट / धुंधला", + "preset.hide.donut": "पिक्सलेट", + "error.cannot_hide": "पिक्सलेट और धुंधला केवल इमेज पर काम करता है", + "hide.save": "छिपाकर सहेजें", + "hide.save_next": "छिपाकर अगला", + "hide.pixelate": "पिक्सलेट", + "hide.pixelate_hint": "औसत रंग के वर्गों से ढकें (P)", + "hide.blur": "धुंधला", + "hide.blur_hint": "गहरे धुंधलेपन से ढकें (B)", + "hide.strength": "तीव्रता", + "hide.strength_value": "तीव्रता {0} / 10 (− / +)", + "hide.remove": "फ़्रेम हटाएँ", + "hide.remove_hint": "चुना गया फ़्रेम हटाएँ (Del)", + "hide.clear": "सभी हटाएँ", + "hide.empty_hint": "जो छिपाना है उस पर खींचें", + "hide.hint_keys": "Tab से फ़्रेम चुनें, ऐरो कुंजियों से खिसकाएँ, Shift से 10 गुना · Del से हटाएँ · Ctrl+Z से पूर्ववत करें · Esc से बंद करें", + "hide.state_done": "हो गया", "preset.clip": "क्लिप काटें", "clip.start": "शुरुआत", "clip.end": "अंत", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 5298e5dfa..c53dc15a4 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -251,6 +251,23 @@ "crop.state_done": "トリミング済み", "crop.state_skipped": "スキップ", "crop.state_failed": "失敗", + "preset.hide": "モザイク・ぼかし", + "preset.hide.donut": "モザイク", + "error.cannot_hide": "モザイクとぼかしは画像でのみ使えます", + "hide.save": "隠して保存", + "hide.save_next": "隠して次へ", + "hide.pixelate": "モザイク", + "hide.pixelate_hint": "平均色のブロックで隠す (P)", + "hide.blur": "ぼかし", + "hide.blur_hint": "強いぼかしで隠す (B)", + "hide.strength": "強さ", + "hide.strength_value": "強さ {0} / 10(− / +)", + "hide.remove": "枠を削除", + "hide.remove_hint": "選択した枠を削除(Del)", + "hide.clear": "すべて消去", + "hide.empty_hint": "隠したい部分をドラッグしてください", + "hide.hint_keys": "Tab で枠を選択、矢印キーで移動、Shift で 10 倍 · Del で削除 · Ctrl+Z で元に戻す · Esc で閉じる", + "hide.state_done": "完了", "preset.clip": "クリップを切り出す", "clip.start": "開始", "clip.end": "終了", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index bb6eb21d8..4559cb0c0 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -260,6 +260,23 @@ "crop.state_done": "자르기 완료", "crop.state_skipped": "건너뜀", "crop.state_failed": "실패", + "preset.hide": "모자이크·흐림", + "preset.hide.donut": "모자이크", + "error.cannot_hide": "모자이크와 흐림은 이미지에만 할 수 있어요", + "hide.save": "가리고 저장", + "hide.save_next": "가리고 다음으로", + "hide.pixelate": "모자이크", + "hide.pixelate_hint": "평균 색 네모로 가리기 (P)", + "hide.blur": "흐림", + "hide.blur_hint": "강하게 흐려서 가리기 (B)", + "hide.strength": "세기", + "hide.strength_value": "세기 {0} / 10 (− / +)", + "hide.remove": "틀 지우기", + "hide.remove_hint": "선택한 틀 지우기 (Del)", + "hide.clear": "모두 지우기", + "hide.empty_hint": "가릴 부분을 드래그하세요", + "hide.hint_keys": "Tab으로 틀 선택, 화살표 키로 이동, Shift는 10배 · Del로 지우기 · Ctrl+Z로 실행 취소 · Esc로 닫기", + "hide.state_done": "완료", "preset.clip": "클립 자르기", "clip.start": "시작", "clip.end": "끝", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 0716ee277..3ede86f25 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -251,6 +251,23 @@ "crop.state_done": "क्रप गरियो", "crop.state_skipped": "छोडियो", "crop.state_failed": "असफल भयो", + "preset.hide": "पिक्सलेट / धमिलो", + "preset.hide.donut": "पिक्सलेट", + "error.cannot_hide": "पिक्सलेट र धमिलो पार्ने कार्य केवल छविहरूमा मात्र काम गर्छ", + "hide.save": "लुकाएर सुरक्षित गर्नुहोस्", + "hide.save_next": "लुकाएर अर्कोमा जानुहोस्", + "hide.pixelate": "पिक्सलेट", + "hide.pixelate_hint": "औसत रङका वर्गहरूले ढाक्नुहोस् (P)", + "hide.blur": "धमिलो", + "hide.blur_hint": "गाढा धमिलोपनले ढाक्नुहोस् (B)", + "hide.strength": "तीव्रता", + "hide.strength_value": "तीव्रता {0} / 10 (− / +)", + "hide.remove": "फ्रेम हटाउनुहोस्", + "hide.remove_hint": "चयन गरिएको फ्रेम हटाउनुहोस् (Del)", + "hide.clear": "सबै हटाउनुहोस्", + "hide.empty_hint": "लुकाउन चाहेको भागमाथि ड्र्याग गर्नुहोस्", + "hide.hint_keys": "Tab ले फ्रेम छान्छ, एरो कीहरूले सार्छन्, Shift थिच्दा १० गुणा · Del ले हटाउँछ · Ctrl+Z ले पूर्वावस्थामा फर्काउँछ · Esc ले बन्द गर्छ", + "hide.state_done": "सम्पन्न", "preset.clip": "क्लिप काट्नुहोस्", "clip.start": "सुरु", "clip.end": "अन्त्य", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 7ea3d9132..7d816cdcf 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -260,6 +260,23 @@ "crop.state_done": "Wykadrowany", "crop.state_skipped": "Pominięty", "crop.state_failed": "Błąd", + "preset.hide": "Pikselizacja / rozmycie", + "preset.hide.donut": "Mozaika", + "error.cannot_hide": "Pikselizować i rozmywać można tylko obrazy", + "hide.save": "Ukryj i zapisz", + "hide.save_next": "Ukryj i dalej", + "hide.pixelate": "Pikselizacja", + "hide.pixelate_hint": "Zakryj kwadratami w średnim kolorze (P)", + "hide.blur": "Rozmycie", + "hide.blur_hint": "Zakryj mocnym rozmyciem (B)", + "hide.strength": "Siła", + "hide.strength_value": "Siła {0} z 10 (− / +)", + "hide.remove": "Usuń ramkę", + "hide.remove_hint": "Usuń zaznaczoną ramkę (Del)", + "hide.clear": "Usuń wszystkie", + "hide.empty_hint": "Przeciągnij po tym, co chcesz ukryć", + "hide.hint_keys": "Tab wybiera ramkę, strzałki ją przesuwają, Shift × 10 · Del ją usuwa · Ctrl+Z cofa · Esc zamyka", + "hide.state_done": "Gotowe", "preset.clip": "Wytnij klip", "clip.start": "Początek", "clip.end": "Koniec", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 140aa9541..795657c39 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -261,6 +261,23 @@ "crop.state_done": "Recortado", "crop.state_skipped": "Pulado", "crop.state_failed": "Falhou", + "preset.hide": "Pixelizar / desfocar", + "preset.hide.donut": "Pixelizar", + "error.cannot_hide": "Só é possível pixelizar ou desfocar imagens", + "hide.save": "Ocultar e salvar", + "hide.save_next": "Ocultar e avançar", + "hide.pixelate": "Pixelizar", + "hide.pixelate_hint": "Cobrir com quadrados da cor média (P)", + "hide.blur": "Desfocar", + "hide.blur_hint": "Cobrir com um desfoque forte (B)", + "hide.strength": "Intensidade", + "hide.strength_value": "Intensidade {0} de 10 (− / +)", + "hide.remove": "Remover moldura", + "hide.remove_hint": "Remover a moldura selecionada (Del)", + "hide.clear": "Remover todas", + "hide.empty_hint": "Arraste sobre o que você quer ocultar", + "hide.hint_keys": "Tab escolhe uma moldura, as setas a movem, Shift × 10 · Del a remove · Ctrl+Z desfaz · Esc fecha", + "hide.state_done": "Concluído", "clip.start": "Início", "clip.end": "Fim", "clip.length": "Duração", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 053279162..6c8a6f9dd 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -260,6 +260,23 @@ "crop.state_done": "Recortado", "crop.state_skipped": "Ignorado", "crop.state_failed": "Falhou", + "preset.hide": "Pixelizar / desfocar", + "preset.hide.donut": "Pixelizar", + "error.cannot_hide": "Só é possível pixelizar ou desfocar imagens", + "hide.save": "Ocultar e guardar", + "hide.save_next": "Ocultar e avançar", + "hide.pixelate": "Pixelizar", + "hide.pixelate_hint": "Cobrir com quadrados da cor média (P)", + "hide.blur": "Desfocar", + "hide.blur_hint": "Cobrir com uma desfocagem forte (B)", + "hide.strength": "Intensidade", + "hide.strength_value": "Intensidade {0} de 10 (− / +)", + "hide.remove": "Remover moldura", + "hide.remove_hint": "Remover a moldura selecionada (Del)", + "hide.clear": "Remover todas", + "hide.empty_hint": "Arraste sobre o que pretende ocultar", + "hide.hint_keys": "Tab escolhe uma moldura, as teclas de direção movem-na, Shift × 10 · Del remove-a · Ctrl+Z anula · Esc fecha", + "hide.state_done": "Concluído", "preset.clip": "Cortar um excerto", "clip.start": "Início", "clip.end": "Fim", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index c6b231493..7be0da3f5 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -260,6 +260,23 @@ "crop.state_done": "Обрезан", "crop.state_skipped": "Пропущен", "crop.state_failed": "Ошибка", + "preset.hide": "Пикселизация / размытие", + "preset.hide.donut": "Мозаика", + "error.cannot_hide": "Пикселизировать и размывать можно только изображения", + "hide.save": "Скрыть и сохранить", + "hide.save_next": "Скрыть и дальше", + "hide.pixelate": "Пикселизация", + "hide.pixelate_hint": "Закрыть квадратами среднего цвета (P)", + "hide.blur": "Размытие", + "hide.blur_hint": "Закрыть сильным размытием (B)", + "hide.strength": "Сила", + "hide.strength_value": "Сила {0} из 10 (− / +)", + "hide.remove": "Удалить рамку", + "hide.remove_hint": "Удалить выбранную рамку (Del)", + "hide.clear": "Удалить все", + "hide.empty_hint": "Выделите мышью то, что нужно скрыть", + "hide.hint_keys": "Tab — выбрать рамку · стрелки — двигать, Shift × 10 · Del — удалить · Ctrl+Z — отменить · Esc — закрыть", + "hide.state_done": "Готово", "preset.clip": "Вырезать клип", "clip.start": "Начало", "clip.end": "Конец", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index bd33fbba3..c2d901e79 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -251,6 +251,23 @@ "crop.state_done": "ครอบตัดแล้ว", "crop.state_skipped": "ข้ามแล้ว", "crop.state_failed": "ล้มเหลว", + "preset.hide": "โมเสก / เบลอ", + "preset.hide.donut": "โมเสก", + "error.cannot_hide": "ใส่โมเสกหรือเบลอได้เฉพาะไฟล์รูปภาพเท่านั้น", + "hide.save": "ซ่อนและบันทึก", + "hide.save_next": "ซ่อนและต่อไป", + "hide.pixelate": "โมเสก", + "hide.pixelate_hint": "ปิดด้วยช่องสี่เหลี่ยมสีเฉลี่ย (P)", + "hide.blur": "เบลอ", + "hide.blur_hint": "ปิดด้วยการเบลออย่างแรง (B)", + "hide.strength": "ความแรง", + "hide.strength_value": "ความแรง {0} / 10 (− / +)", + "hide.remove": "ลบกรอบ", + "hide.remove_hint": "ลบกรอบที่เลือก (Del)", + "hide.clear": "ลบทั้งหมด", + "hide.empty_hint": "ลากคลุมส่วนที่ต้องการซ่อน", + "hide.hint_keys": "Tab เพื่อเลือกกรอบ, ปุ่มลูกศรเพื่อเลื่อน, Shift × 10 · Del เพื่อลบ · Ctrl+Z เพื่อเลิกทำ · Esc เพื่อปิด", + "hide.state_done": "เสร็จแล้ว", "preset.clip": "ตัดคลิป", "clip.start": "เริ่ม", "clip.end": "สิ้นสุด", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 0a0ddf6c7..73f145424 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -251,6 +251,23 @@ "crop.state_done": "Kırpıldı", "crop.state_skipped": "Atlandı", "crop.state_failed": "Başarısız", + "preset.hide": "Pikselleştir / bulanıklaştır", + "preset.hide.donut": "Mozaik", + "error.cannot_hide": "Yalnızca görseller pikselleştirilebilir veya bulanıklaştırılabilir", + "hide.save": "Gizle ve kaydet", + "hide.save_next": "Gizle ve ilerle", + "hide.pixelate": "Pikselleştir", + "hide.pixelate_hint": "Ortalama renkteki karelerle kapat (P)", + "hide.blur": "Bulanıklaştır", + "hide.blur_hint": "Güçlü bir bulanıklıkla kapat (B)", + "hide.strength": "Güç", + "hide.strength_value": "Güç {0} / 10 (− / +)", + "hide.remove": "Çerçeveyi sil", + "hide.remove_hint": "Seçili çerçeveyi sil (Del)", + "hide.clear": "Tümünü sil", + "hide.empty_hint": "Gizlemek istediğiniz yerin üzerine sürükleyin", + "hide.hint_keys": "Tab çerçeve seçer, ok tuşları taşır, Shift × 10 · Del siler · Ctrl+Z geri alır · Esc kapatır", + "hide.state_done": "Tamamlandı", "preset.clip": "Klip kes", "clip.start": "Başlangıç", "clip.end": "Bitiş", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 7f250fbde..72ba92d06 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -260,6 +260,23 @@ "crop.state_done": "已裁剪", "crop.state_skipped": "已跳过", "crop.state_failed": "失败", + "preset.hide": "马赛克/模糊", + "preset.hide.donut": "马赛克", + "error.cannot_hide": "只能对图片打马赛克或模糊", + "hide.save": "遮盖并保存", + "hide.save_next": "遮盖并下一张", + "hide.pixelate": "马赛克", + "hide.pixelate_hint": "用平均色方块遮盖 (P)", + "hide.blur": "模糊", + "hide.blur_hint": "用强模糊遮盖 (B)", + "hide.strength": "强度", + "hide.strength_value": "强度 {0} / 10(− / +)", + "hide.remove": "删除选框", + "hide.remove_hint": "删除所选选框(Del)", + "hide.clear": "全部清除", + "hide.empty_hint": "拖动框选要隐藏的部分", + "hide.hint_keys": "Tab 选择选框,方向键移动,Shift 为 10 倍 · Del 删除 · Ctrl+Z 撤销 · Esc 关闭", + "hide.state_done": "完成", "preset.clip": "剪切片段", "clip.start": "开始", "clip.end": "结束", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 2d8f19781..76a52b467 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -260,6 +260,23 @@ "crop.state_done": "已裁剪", "crop.state_skipped": "已跳過", "crop.state_failed": "失敗", + "preset.hide": "馬賽克/模糊", + "preset.hide.donut": "馬賽克", + "error.cannot_hide": "只能對圖片加上馬賽克或模糊", + "hide.save": "遮蓋並儲存", + "hide.save_next": "遮蓋並下一張", + "hide.pixelate": "馬賽克", + "hide.pixelate_hint": "以平均色方塊遮蓋 (P)", + "hide.blur": "模糊", + "hide.blur_hint": "以強烈模糊遮蓋 (B)", + "hide.strength": "強度", + "hide.strength_value": "強度 {0} / 10(− / +)", + "hide.remove": "刪除選框", + "hide.remove_hint": "刪除所選選框(Del)", + "hide.clear": "全部清除", + "hide.empty_hint": "拖曳框選要隱藏的部分", + "hide.hint_keys": "Tab 選擇選框,方向鍵移動,Shift 為 10 倍 · Del 刪除 · Ctrl+Z 復原 · Esc 關閉", + "hide.state_done": "完成", "preset.clip": "剪輯片段", "clip.start": "開始", "clip.end": "結束", diff --git a/src/Filee.App/Controls/CropCanvas.cs b/src/Filee.App/Controls/CropCanvas.cs index 9b7cfad60..3b0f358bc 100644 --- a/src/Filee.App/Controls/CropCanvas.cs +++ b/src/Filee.App/Controls/CropCanvas.cs @@ -140,13 +140,13 @@ public double AspectRatio private static readonly Cursor TopRightCursor = new(StandardCursorType.TopRightCorner); private static readonly Cursor CrossCursor = new(StandardCursorType.Cross); - private static readonly IPen FrameShadowPen = new Pen(new SolidColorBrush(Color.FromArgb(70, 0, 0, 0)), 4); + internal static readonly IPen FrameShadowPen = new Pen(new SolidColorBrush(Color.FromArgb(70, 0, 0, 0)), 4); private static readonly IPen GridPen = new Pen(new SolidColorBrush(Color.FromArgb(170, 255, 255, 255)), 1); private static readonly IPen GridShadowPen = new Pen(new SolidColorBrush(Color.FromArgb(60, 0, 0, 0)), 1); private static readonly IPen HandleRingPen = new Pen(Brushes.White, 2.5); private static readonly IBrush HandleShadowBrush = new SolidColorBrush(Color.FromArgb(60, 0, 0, 0)); - private static readonly BoxShadows PictureShadow = BoxShadows.Parse("0 10 30 0 #38000000"); - private static readonly BoxShadows ChipShadow = BoxShadows.Parse("0 2 8 0 #26000000"); + internal static readonly BoxShadows PictureShadow = BoxShadows.Parse("0 10 30 0 #38000000"); + internal static readonly BoxShadows ChipShadow = BoxShadows.Parse("0 2 8 0 #26000000"); private static double SafeClamp(double val, double min, double max) => min > max ? Math.Clamp(val, max, min) : Math.Clamp(val, min, max); @@ -420,16 +420,18 @@ public override void Render(DrawingContext context) private bool IsDark => ActualThemeVariant == ThemeVariant.Dark; - /// The backdrop: the page colour dimmed, with two soft glows of the accent colour. - private void DrawStage(DrawingContext context, Rect bounds) + private void DrawStage(DrawingContext context, Rect bounds) => DrawStage(context, bounds, StageBrush, AccentBrush, IsDark); + + /// The backdrop: the page colour dimmed, with two soft glows of the accent colour (also the hide window's). + internal static void DrawStage(DrawingContext context, Rect bounds, IBrush? stageBrush, IBrush? accentBrush, bool dark) { - if (StageBrush is { } stage) + if (stageBrush is { } stage) context.FillRectangle(stage, bounds); - context.FillRectangle(new SolidColorBrush(Colors.Black, IsDark ? 0.28 : 0.06), bounds); + context.FillRectangle(new SolidColorBrush(Colors.Black, dark ? 0.28 : 0.06), bounds); - if (AccentBrush is ISolidColorBrush { Color: var accent }) + if (accentBrush is ISolidColorBrush { Color: var accent }) { - var strength = IsDark ? 0.16 : 0.22; + var strength = dark ? 0.16 : 0.22; Glow(context, accent, new Point(bounds.Width * 0.12, bounds.Height * 0.10), bounds.Width * 0.45, strength); Glow(context, accent, new Point(bounds.Width * 0.92, bounds.Height * 0.95), bounds.Width * 0.38, strength * 0.7); } @@ -499,18 +501,23 @@ private void DrawFrame(DrawingContext context, Rect crop, long now) if ((horizontal ? crop.Width : crop.Height) < 90) continue; var grow = handle == _poppedHandle ? pop : 0; - var length = 22 + 8 * grow; - const double thickness = 7; - var c = HandlePoint(crop, handle); - var rect = horizontal - ? new Rect(c.X - length / 2, c.Y - thickness / 2, length, thickness) - : new Rect(c.X - thickness / 2, c.Y - length / 2, thickness, length); - context.DrawRectangle(HandleShadowBrush, null, new RoundedRect(rect.Translate(new Vector(0, 1)).Inflate(1.5), thickness)); - context.DrawRectangle(accent, HandleRingPen, new RoundedRect(rect, thickness / 2)); + DrawPill(context, accent, HandlePoint(crop, handle), horizontal, 22 + 8 * grow); } } - private static void DrawDot(DrawingContext context, IBrush accent, Point center, double radius) + /// A handle on the middle of an edge: a pill in the accent colour with a white ring. + internal static void DrawPill(DrawingContext context, IBrush accent, Point c, bool horizontal, double length) + { + const double thickness = 7; + var rect = horizontal + ? new Rect(c.X - length / 2, c.Y - thickness / 2, length, thickness) + : new Rect(c.X - thickness / 2, c.Y - length / 2, thickness, length); + context.DrawRectangle(HandleShadowBrush, null, new RoundedRect(rect.Translate(new Vector(0, 1)).Inflate(1.5), thickness)); + context.DrawRectangle(accent, HandleRingPen, new RoundedRect(rect, thickness / 2)); + } + + /// A corner handle: a dot in the accent colour with a white ring. + internal static void DrawDot(DrawingContext context, IBrush accent, Point center, double radius) { context.DrawEllipse(HandleShadowBrush, null, center + new Vector(0, 1), radius + 2, radius + 2); context.DrawEllipse(accent, HandleRingPen, center, radius, radius); @@ -562,12 +569,12 @@ private void DrawSizeChip(DrawingContext context, Rect crop, Rect imgBounds, Rec /// internal static Func Clock { get; set; } = Stopwatch.GetTimestamp; - private static long Now() => Clock(); + internal static long Now() => Clock(); - private static double EaseOutCubic(double t) => 1 - Math.Pow(1 - Math.Clamp(t, 0, 1), 3); + internal static double EaseOutCubic(double t) => 1 - Math.Pow(1 - Math.Clamp(t, 0, 1), 3); /// Overshoots a little before settling: the "squishy" grow of a handle. - private static double EaseOutBack(double t) + internal static double EaseOutBack(double t) { t = Math.Clamp(t, 0, 1); const double s = 1.9; @@ -624,7 +631,7 @@ private void OnFrame(TimeSpan _) } /// A value easing from to over a time, read against the clock. - private readonly record struct Tween(double From, double To, long Start, double Seconds) + internal readonly record struct Tween(double From, double To, long Start, double Seconds) { public static Tween Done(double value) => new(value, value, 0, 0); diff --git a/src/Filee.App/Controls/HideAreaSet.cs b/src/Filee.App/Controls/HideAreaSet.cs new file mode 100644 index 000000000..eea6b4974 --- /dev/null +++ b/src/Filee.App/Controls/HideAreaSet.cs @@ -0,0 +1,205 @@ +// The parts of a picture the "Pixelate / blur" window hides, without a window: the frames (in parts of the picture), +// the one selected, and undo / redo of adding, moving, sizing and removing them. The canvas draws and drags them, the +// keys move and remove them, the window's view model turns them into the conversion. + +using Avalonia; + +namespace Filee.App.Controls; + +public sealed class HideAreaSet +{ + /// Most parts one picture takes: more are a slip of the hand, not something to hide. + public const int MaxAreas = 100; + + /// Undo steps kept. + private const int MaxUndo = 100; + + private readonly List _areas = []; + private readonly List _undo = []; + private readonly List _redo = []; + + /// The state when a drag began (): one undo step however far it went. + private Snapshot? _gesture; + + private readonly record struct Snapshot(Rect[] Areas, int Selected); + + /// The frames, in parts of the picture (0 to 1), in the order they were drawn (the last on top). + public IReadOnlyList Areas => _areas; + + /// The selected frame, -1 for none. + public int Selected { get; private set; } = -1; + + public Rect? SelectedArea => Selected >= 0 ? _areas[Selected] : null; + public int Count => _areas.Count; + public bool CanUndo => _undo.Count > 0; + public bool CanRedo => _redo.Count > 0; + + /// Something changed: the frames, the selection or what can be undone. + public event Action? Changed; + + /// A drag starts: what it does until is undone in one step. + public void Begin() => _gesture ??= Take(); + + /// The drag ends: one undo step when it changed the frames. + public void End() + { + if (_gesture is not { } before) + return; + _gesture = null; + if (!before.Areas.SequenceEqual(_areas)) + Push(before); + Changed?.Invoke(); + } + + /// Adds a frame and selects it; false when there are already. + public bool Add(Rect area) + { + if (_areas.Count >= MaxAreas) + return false; + Record(); + _areas.Add(Fit(area)); + Selected = _areas.Count - 1; + Changed?.Invoke(); + return true; + } + + /// Moves or sizes frame (a drag; see ). + public void Set(int index, Rect area) + { + if (index < 0 || index >= _areas.Count) + return; + var fitted = Fit(area); + if (_areas[index] == fitted) + return; + Record(); + _areas[index] = fitted; + Changed?.Invoke(); + } + + public void Select(int index) + { + index = index >= 0 && index < _areas.Count ? index : -1; + if (index == Selected) + return; + Selected = index; + Changed?.Invoke(); + } + + /// + /// The next (or previous) frame in the order they were drawn, round and round: from none, the first (or the last). + /// + public void SelectNext(int step) + { + if (_areas.Count == 0) + return; + Select(Selected < 0 + ? (step >= 0 ? 0 : _areas.Count - 1) + : ((Selected + step) % _areas.Count + _areas.Count) % _areas.Count); + } + + /// Removes the selected frame; the one drawn before it is selected next. False when none is selected. + public bool RemoveSelected() + { + if (Selected < 0) + return false; + Record(); + _areas.RemoveAt(Selected); + Selected = _areas.Count == 0 ? -1 : Math.Max(0, Selected - 1); + Changed?.Invoke(); + return true; + } + + /// Removes every frame (undoable). + public void Clear() + { + if (_areas.Count == 0) + return; + Record(); + _areas.Clear(); + Selected = -1; + Changed?.Invoke(); + } + + /// Moves the selected frame by whole pixels of a × picture. + public void Nudge(int dx, int dy, int width, int height) + { + if (Selected < 0) + return; + Set(Selected, CropMath.Nudge(_areas[Selected], dx, dy, width, height)); + } + + /// + /// Starts over with (a new picture: the last one's frames carry on), none selected and + /// nothing to undo. + /// + public void Reset(IEnumerable areas) + { + _gesture = null; + _areas.Clear(); + _areas.AddRange(areas.Select(Fit).Take(MaxAreas)); + _undo.Clear(); + _redo.Clear(); + Selected = -1; + Changed?.Invoke(); + } + + public void Undo() => Step(_undo, _redo); + + public void Redo() => Step(_redo, _undo); + + /// + /// The frame on top under (in parts of the picture), -1 for none; the selected one first, so + /// a frame drawn over it later doesn't take it away while it is being worked on. + /// + public int HitTest(Point point) + { + if (Selected >= 0 && _areas[Selected].Contains(point)) + return Selected; + for (var i = _areas.Count - 1; i >= 0; i--) + { + if (_areas[i].Contains(point)) + return i; + } + return -1; + } + + private void Step(List from, List to) + { + if (from.Count == 0 || _gesture is not null) + return; + var snapshot = from[^1]; + from.RemoveAt(from.Count - 1); + to.Add(Take()); + _areas.Clear(); + _areas.AddRange(snapshot.Areas); + Selected = Math.Min(snapshot.Selected, _areas.Count - 1); + Changed?.Invoke(); + } + + /// An undo step for a change made now, unless a drag records one for itself. + private void Record() + { + if (_gesture is null) + Push(Take()); + } + + private void Push(Snapshot snapshot) + { + _undo.Add(snapshot); + if (_undo.Count > MaxUndo) + _undo.RemoveAt(0); + _redo.Clear(); + } + + private Snapshot Take() => new([.. _areas], Selected); + + /// The frame inside the picture. + private static Rect Fit(Rect area) + { + var left = Math.Clamp(Math.Min(area.Left, area.Right), 0, 1); + var top = Math.Clamp(Math.Min(area.Top, area.Bottom), 0, 1); + var right = Math.Clamp(Math.Max(area.Left, area.Right), left, 1); + var bottom = Math.Clamp(Math.Max(area.Top, area.Bottom), top, 1); + return new Rect(left, top, right - left, bottom - top); + } +} diff --git a/src/Filee.App/Controls/HideCanvas.cs b/src/Filee.App/Controls/HideCanvas.cs new file mode 100644 index 000000000..89a694185 --- /dev/null +++ b/src/Filee.App/Controls/HideCanvas.cs @@ -0,0 +1,644 @@ +// The "Pixelate / blur" window's stage, drawn as the crop overlay's: the picture on the dimmed backdrop with soft accent +// glows, and over it the frames of the parts to hide, each already showing the picture pixelated or blurred inside it +// (a copy of the whole preview covered once per choice, drawn clipped to the frames, so drawing and dragging cost +// nothing). The selected frame has the crop frame's accent outline, round corner handles and edge pills, and a size chip; +// the others a thinner, lighter accent outline that turns full under the pointer. Dragging on the picture draws a new +// frame, dragging a frame moves it (snapping to the picture's centre lines), its handles size it, the wheel grows or +// shrinks it. + +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Styling; +using static Filee.App.Controls.CropCanvas; + +namespace Filee.App.Controls; + +public sealed class HideCanvas : Control +{ + public static readonly StyledProperty SourceImageProperty = + AvaloniaProperty.Register(nameof(SourceImage)); + + /// The whole preview pixelated or blurred as chosen: what the frames show. + public static readonly StyledProperty CoverImageProperty = + AvaloniaProperty.Register(nameof(CoverImage)); + + public static readonly StyledProperty AreasProperty = + AvaloniaProperty.Register(nameof(Areas)); + + /// Room kept free around the picture for the floating chips and the toolbar. + public static readonly StyledProperty ImagePaddingProperty = + AvaloniaProperty.Register(nameof(ImagePadding)); + + /// The picture's size in pixels: the size chip, and arrow keys move frames by whole pixels of it. + public static readonly StyledProperty PixelSizeProperty = + AvaloniaProperty.Register(nameof(PixelSize)); + + public static readonly StyledProperty AccentBrushProperty = + AvaloniaProperty.Register(nameof(AccentBrush), Brushes.MediumPurple); + + public static readonly StyledProperty StageBrushProperty = + AvaloniaProperty.Register(nameof(StageBrush)); + + public static readonly StyledProperty ChipBackgroundProperty = + AvaloniaProperty.Register(nameof(ChipBackground), Brushes.White); + + public static readonly StyledProperty ChipForegroundProperty = + AvaloniaProperty.Register(nameof(ChipForeground), Brushes.Black); + + /// False with reduced motion: every change shows at once. + public static readonly StyledProperty PlayAnimationsProperty = + AvaloniaProperty.Register(nameof(PlayAnimations), true); + + public Bitmap? SourceImage { get => GetValue(SourceImageProperty); set => SetValue(SourceImageProperty, value); } + public Bitmap? CoverImage { get => GetValue(CoverImageProperty); set => SetValue(CoverImageProperty, value); } + public HideAreaSet? Areas { get => GetValue(AreasProperty); set => SetValue(AreasProperty, value); } + public Thickness ImagePadding { get => GetValue(ImagePaddingProperty); set => SetValue(ImagePaddingProperty, value); } + public PixelSize PixelSize { get => GetValue(PixelSizeProperty); set => SetValue(PixelSizeProperty, value); } + public IBrush? AccentBrush { get => GetValue(AccentBrushProperty); set => SetValue(AccentBrushProperty, value); } + public IBrush? StageBrush { get => GetValue(StageBrushProperty); set => SetValue(StageBrushProperty, value); } + public IBrush? ChipBackground { get => GetValue(ChipBackgroundProperty); set => SetValue(ChipBackgroundProperty, value); } + public IBrush? ChipForeground { get => GetValue(ChipForegroundProperty); set => SetValue(ChipForegroundProperty, value); } + public bool PlayAnimations { get => GetValue(PlayAnimationsProperty); set => SetValue(PlayAnimationsProperty, value); } + + /// The smallest frame a drag makes or a handle leaves, in screen pixels. + private const double MinSizePx = 6; + + /// How far the pointer has to move before a press on the picture becomes a new frame (else it is a click). + private const double DragThreshold = 4; + + private enum Drag + { + None, + Pending, + New, + Body, + TopLeft, + Top, + TopRight, + Right, + BottomRight, + Bottom, + BottomLeft, + Left, + } + + private Drag _drag = Drag.None; + private int _dragIndex = -1; + private Point _dragStartPoint; + private Rect _dragStartFrame; // on screen + private bool _snappedX, _snappedY; + private int _hoverIndex = -1; + private Drag _hoverHandle = Drag.None; + + // ───── Motion (the crop overlay's) ───── + + private const double AppearSeconds = 0.42; // a new picture + private const double HandleSeconds = 0.26; + private Tween _appear = Tween.Done(1); + private Tween _handlePop = Tween.Done(0); + private Drag _poppedHandle = Drag.None; + private bool _looping; + + private Bitmap? _soft; // a tiny copy: the frames' fill until the covered copy is ready + private FormattedText? _chipText; + private string? _chipTextFor; + private IBrush? _chipTextBrush; + + private static readonly Cursor ArrowCursor = new(StandardCursorType.Arrow); + private static readonly Cursor SizeAllCursor = new(StandardCursorType.SizeAll); + private static readonly Cursor SizeNorthSouthCursor = new(StandardCursorType.SizeNorthSouth); + private static readonly Cursor SizeWestEastCursor = new(StandardCursorType.SizeWestEast); + private static readonly Cursor TopLeftCursor = new(StandardCursorType.TopLeftCorner); + private static readonly Cursor TopRightCursor = new(StandardCursorType.TopRightCorner); + private static readonly Cursor CrossCursor = new(StandardCursorType.Cross); + + + + static HideCanvas() + { + AffectsRender(SourceImageProperty, CoverImageProperty, ImagePaddingProperty, AccentBrushProperty, + StageBrushProperty, ChipBackgroundProperty, ChipForegroundProperty, PixelSizeProperty); + FocusableProperty.OverrideDefaultValue(false); + } + + // The picture is never mirrored, also in a right-to-left UI. + public HideCanvas() => FlowDirection = FlowDirection.LeftToRight; + + /// The picture's place: as large as fits inside , centred. + public Rect CalculateImageBounds() + { + if (SourceImage is null || Bounds.Width <= 0 || Bounds.Height <= 0) + return default; + var area = new Rect(Bounds.Size).Deflate(ImagePadding); + if (area.Width <= 0 || area.Height <= 0) + area = new Rect(Bounds.Size); + var size = SourceImage.Size; + var scale = Math.Min(area.Width / size.Width, area.Height / size.Height); + var w = size.Width * scale; + var h = size.Height * scale; + return new Rect(area.X + (area.Width - w) / 2, area.Y + (area.Height - h) / 2, w, h); + } + + /// A frame (in parts of the picture) on screen. + public static Rect ToScreen(Rect area, Rect picture) => new( + picture.X + area.X * picture.Width, picture.Y + area.Y * picture.Height, + area.Width * picture.Width, area.Height * picture.Height); + + private static Rect ToPicture(Rect screen, Rect picture) => new( + (screen.X - picture.X) / picture.Width, (screen.Y - picture.Y) / picture.Height, + screen.Width / picture.Width, screen.Height / picture.Height); + + /// Ends every running animation (tests capture the settled picture). + internal void FinishAnimations() + { + _appear = Tween.Done(1); + _handlePop = Tween.Done(_handlePop.To); + InvalidateVisual(); + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + if (change.Property == AreasProperty) + { + if (change.GetOldValue() is { } old) + old.Changed -= OnAreasChanged; + if (change.GetNewValue() is { } areas) + areas.Changed += OnAreasChanged; + InvalidateVisual(); + } + else if (change.Property == SourceImageProperty) + { + _soft?.Dispose(); + _soft = Soften(change.GetNewValue()); + CancelDrag(); + if (change.GetNewValue() is not null && PlayAnimations) + { + _appear = Tween.Begin(0, 1, AppearSeconds); + StartLoop(); + } + else + { + _appear = Tween.Done(1); + } + } + else if (change.Property == PlayAnimationsProperty && !change.GetNewValue()) + { + FinishAnimations(); + } + base.OnPropertyChanged(change); + } + + private void OnAreasChanged() => InvalidateVisual(); + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _soft ??= Soften(SourceImage); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _soft?.Dispose(); + _soft = null; + } + + /// A copy at most 24 px across: drawn as large as the picture it is a soft blur, at no cost per frame. + private static Bitmap? Soften(Bitmap? source) + { + if (source is null) + return null; + try + { + var size = source.PixelSize; + var scale = Math.Min(1, 24.0 / Math.Max(size.Width, size.Height)); + var target = new PixelSize(Math.Max(1, (int)Math.Round(size.Width * scale)), Math.Max(1, (int)Math.Round(size.Height * scale))); + return source.CreateScaledBitmap(target, BitmapInterpolationMode.HighQuality); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + return null; + } + } + + // ───────────────────────── Drawing ───────────────────────── + + private bool IsDark => ActualThemeVariant == ThemeVariant.Dark; + + public override void Render(DrawingContext context) + { + base.Render(context); + var now = CropCanvas.Now(); + var bounds = new Rect(Bounds.Size); + CropCanvas.DrawStage(context, bounds, StageBrush, AccentBrush, IsDark); + + var picture = CalculateImageBounds(); + if (SourceImage is null || picture.Width <= 0 || picture.Height <= 0) + return; + + // A new picture settles in: a little smaller and transparent at first. + var appear = EaseOutCubic(_appear.Value(now)); + var scale = 0.965 + 0.035 * appear; + var center = picture.Center; + using var appearing = context.PushTransform( + Matrix.CreateTranslation(-center.X, -center.Y) * Matrix.CreateScale(scale, scale) * Matrix.CreateTranslation(center.X, center.Y)); + using var fading = context.PushOpacity(appear); + + context.DrawRectangle(StageBrush ?? Brushes.Gray, null, new RoundedRect(picture), PictureShadow); + using (context.PushRenderOptions(new RenderOptions { BitmapInterpolationMode = BitmapInterpolationMode.HighQuality })) + context.DrawImage(SourceImage, new Rect(SourceImage.Size), picture); + + if (Areas is not { } areas) + return; + + // Inside every frame the covered picture: crisp squares for pixelate (the copy has them at the preview's size). + var cover = CoverImage ?? _soft; + for (var i = 0; i < areas.Count; i++) + { + var frame = ToScreen(areas.Areas[i], picture); + if (cover is null || frame.Width <= 0 || frame.Height <= 0) + continue; + using (context.PushClip(frame)) + using (context.PushRenderOptions(new RenderOptions + { + BitmapInterpolationMode = CoverImage is null ? BitmapInterpolationMode.HighQuality : BitmapInterpolationMode.MediumQuality, + })) + context.DrawImage(cover, new Rect(cover.Size), picture); + } + + var accent = AccentBrush ?? Brushes.MediumPurple; + for (var i = 0; i < areas.Count; i++) + { + if (i == areas.Selected) + continue; + var frame = Snap(ToScreen(areas.Areas[i], picture)); + if (i == _hoverIndex && _drag == Drag.None) + { + context.DrawRectangle(null, FrameShadowPen, frame); + context.DrawRectangle(null, new Pen(accent, 2), frame); + } + else + { + using (context.PushOpacity(0.75)) + context.DrawRectangle(null, new Pen(accent, 1.5), frame); + } + } + + DrawCentreGuides(context, picture, accent); + if (areas.SelectedArea is { } selected) + { + var frame = ToScreen(selected, picture); + DrawSelected(context, frame, accent, now); + DrawSizeChip(context, frame, selected, picture, bounds); + } + } + + /// A frame on whole screen pixels, so its outline stays sharp. + private static Rect Snap(Rect frame) => + new(Math.Round(frame.X), Math.Round(frame.Y), Math.Max(1, Math.Round(frame.Width)), Math.Max(1, Math.Round(frame.Height))); + + private void DrawSelected(DrawingContext context, Rect frame, IBrush accent, long now) + { + context.DrawRectangle(null, FrameShadowPen, frame); + context.DrawRectangle(null, new Pen(accent, 2), frame); + + var pop = EaseOutBack(_handlePop.Value(now)); + // Smaller dots on a small frame, so they don't cover it. + var dot = Math.Clamp(Math.Min(frame.Width, frame.Height) / 4, 3.5, 6.5); + foreach (var handle in new[] { Drag.TopLeft, Drag.TopRight, Drag.BottomRight, Drag.BottomLeft }) + { + var grow = handle == _poppedHandle ? pop : 0; + DrawDot(context, accent, HandlePoint(frame, handle), dot + 2.5 * grow); + } + foreach (var handle in new[] { Drag.Top, Drag.Right, Drag.Bottom, Drag.Left }) + { + var horizontal = handle is Drag.Top or Drag.Bottom; + if ((horizontal ? frame.Width : frame.Height) < 90) + continue; + var grow = handle == _poppedHandle ? pop : 0; + DrawPill(context, accent, HandlePoint(frame, handle), horizontal, 22 + 8 * grow); + } + } + + private void DrawCentreGuides(DrawingContext context, Rect picture, IBrush accent) + { + if (_drag != Drag.Body || !(_snappedX || _snappedY)) + return; + var pen = new Pen(accent, 1, DashStyle.Dash); + var x = Math.Round(picture.Center.X) + 0.5; + var y = Math.Round(picture.Center.Y) + 0.5; + if (_snappedX) + context.DrawLine(pen, new Point(x, picture.Top), new Point(x, picture.Bottom)); + if (_snappedY) + context.DrawLine(pen, new Point(picture.Left, y), new Point(picture.Right, y)); + } + + /// How far below the picture the size chip may go (the toolbar starts further down). + private const double ChipReach = 40; + + private void DrawSizeChip(DrawingContext context, Rect frame, Rect area, Rect picture, Rect bounds) + { + var pixels = PixelSize; + if (pixels.Width <= 0 || pixels.Height <= 0) + return; + var size = CropMath.PixelSize(area, pixels.Width, pixels.Height); + var text = $"{size.Width} × {size.Height} px"; + var foreground = ChipForeground ?? Brushes.Black; + if (_chipText is null || _chipTextFor != text || !ReferenceEquals(_chipTextBrush, foreground)) + { + var typeface = new Typeface(GetValue(TextElement.FontFamilyProperty), FontStyle.Normal, FontWeight.SemiBold); + _chipText = new FormattedText(text, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, typeface, 11.5, foreground); + _chipTextFor = text; + _chipTextBrush = foreground; + } + var chip = new Size(Math.Ceiling(_chipText.Width) + 20, Math.Ceiling(_chipText.Height) + 8); + var room = new Rect(picture.Left, picture.Top, picture.Width, picture.Height + ChipReach).Intersect(bounds.Deflate(6)); + var origin = CropMath.SizeChipOrigin(frame, chip, room); + context.DrawRectangle(ChipBackground, null, new RoundedRect(new Rect(origin, chip), chip.Height / 2), ChipShadow); + context.DrawText(_chipText, new Point(origin.X + 10, origin.Y + 4)); + } + + private static Point HandlePoint(Rect frame, Drag handle) => handle switch + { + Drag.TopLeft => frame.TopLeft, + Drag.TopRight => frame.TopRight, + Drag.BottomRight => frame.BottomRight, + Drag.BottomLeft => frame.BottomLeft, + Drag.Top => new Point(frame.Center.X, frame.Top), + Drag.Bottom => new Point(frame.Center.X, frame.Bottom), + Drag.Left => new Point(frame.Left, frame.Center.Y), + Drag.Right => new Point(frame.Right, frame.Center.Y), + _ => frame.Center, + }; + + // ───────────────────────── Motion ───────────────────────── + + private void PopHandle(Drag handle) + { + if (handle is Drag.None or Drag.Pending or Drag.New or Drag.Body) + handle = Drag.None; + if (handle == _poppedHandle) + return; + _poppedHandle = handle; + _handlePop = handle == Drag.None || !PlayAnimations + ? Tween.Done(handle == Drag.None ? 0 : 1) + : Tween.Begin(0, 1, HandleSeconds); + StartLoop(); + } + + private void StartLoop() + { + InvalidateVisual(); + if (_looping || TopLevel.GetTopLevel(this) is not { } top) + return; + _looping = true; + top.RequestAnimationFrame(OnFrame); + } + + private void OnFrame(TimeSpan _) + { + InvalidateVisual(); + var now = CropCanvas.Now(); + if ((_appear.IsRunning(now) || _handlePop.IsRunning(now)) && TopLevel.GetTopLevel(this) is { } top) + { + top.RequestAnimationFrame(OnFrame); + return; + } + _looping = false; + } + + // ───────────────────────── Pointer ───────────────────────── + + /// What a press at would drag, and which frame. + private (Drag Drag, int Index) HitTest(Point point, Rect picture) + { + if (Areas is not { } areas || picture.Width <= 0 || picture.Height <= 0) + return (Drag.None, -1); + if (areas.SelectedArea is { } selected) + { + var frame = ToScreen(selected, picture); + // A small frame keeps room in its middle for moving it. + var tolerance = Math.Clamp(Math.Min(frame.Width, frame.Height) / 3, 5, 14); + foreach (var corner in new[] { Drag.TopLeft, Drag.TopRight, Drag.BottomLeft, Drag.BottomRight }) + { + if (Distance(point, HandlePoint(frame, corner)) <= tolerance) + return (corner, areas.Selected); + } + var alongX = point.X >= frame.Left && point.X <= frame.Right; + var alongY = point.Y >= frame.Top && point.Y <= frame.Bottom; + if (alongX && Math.Abs(point.Y - frame.Top) <= tolerance) return (Drag.Top, areas.Selected); + if (alongX && Math.Abs(point.Y - frame.Bottom) <= tolerance) return (Drag.Bottom, areas.Selected); + if (alongY && Math.Abs(point.X - frame.Left) <= tolerance) return (Drag.Left, areas.Selected); + if (alongY && Math.Abs(point.X - frame.Right) <= tolerance) return (Drag.Right, areas.Selected); + } + var index = areas.HitTest(ToPicture(new Rect(point, new Size(0, 0)), picture).TopLeft); + if (index >= 0) + return (Drag.Body, index); + return picture.Contains(point) ? (Drag.Pending, -1) : (Drag.None, -1); + } + + private static double Distance(Point a, Point b) => Math.Sqrt(Math.Pow(a.X - b.X, 2) + Math.Pow(a.Y - b.Y, 2)); + + private static Cursor CursorFor(Drag drag) => drag switch + { + Drag.TopLeft or Drag.BottomRight => TopLeftCursor, + Drag.TopRight or Drag.BottomLeft => TopRightCursor, + Drag.Top or Drag.Bottom => SizeNorthSouthCursor, + Drag.Left or Drag.Right => SizeWestEastCursor, + Drag.Body => SizeAllCursor, + Drag.Pending or Drag.New => CrossCursor, + _ => ArrowCursor, + }; + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + if (_drag != Drag.None) + return; + _hoverHandle = Drag.None; + _hoverIndex = -1; + PopHandle(Drag.None); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + var picture = CalculateImageBounds(); + if (Areas is not { } areas || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed || picture.Width <= 0) + return; + var point = e.GetPosition(this); + var (drag, index) = HitTest(point, picture); + if (drag == Drag.None) + return; + + _drag = drag; + _dragIndex = index; + _dragStartPoint = point; + _snappedX = _snappedY = false; + if (index >= 0) + { + areas.Select(index); + _dragStartFrame = ToScreen(areas.Areas[index], picture); + areas.Begin(); + } + e.Pointer.Capture(this); + e.Handled = true; + PopHandle(drag); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + var picture = CalculateImageBounds(); + if (Areas is not { } areas || picture.Width <= 0 || picture.Height <= 0) + return; + var point = e.GetPosition(this); + + if (_drag == Drag.None) + { + var (hover, index) = HitTest(point, picture); + Cursor = CursorFor(hover); + if (index != _hoverIndex) + { + _hoverIndex = index; + InvalidateVisual(); + } + _hoverHandle = hover; + PopHandle(hover); + return; + } + + var start = Clamp(_dragStartPoint, picture); + var at = Clamp(point, picture); + switch (_drag) + { + case Drag.Pending: + if (Distance(point, _dragStartPoint) < DragThreshold) + return; + areas.Begin(); + if (!areas.Add(ToPicture(FrameBetween(start, at, picture), picture))) + { + areas.End(); + _drag = Drag.None; + return; + } + _drag = Drag.New; + _dragIndex = areas.Selected; + break; + case Drag.New: + areas.Set(_dragIndex, ToPicture(FrameBetween(start, at, picture), picture)); + break; + case Drag.Body: + { + var x = SafeClamp(_dragStartFrame.X + point.X - _dragStartPoint.X, picture.Left, picture.Right - _dragStartFrame.Width); + var y = SafeClamp(_dragStartFrame.Y + point.Y - _dragStartPoint.Y, picture.Top, picture.Bottom - _dragStartFrame.Height); + (x, _snappedX) = CropMath.SnapToCentre(x, _dragStartFrame.Width, picture.Left, picture.Right); + (y, _snappedY) = CropMath.SnapToCentre(y, _dragStartFrame.Height, picture.Top, picture.Bottom); + var moved = new Rect(x, y, _dragStartFrame.Width, _dragStartFrame.Height); + // Kept at its size in parts of the picture: rounding on the way to the screen and back must not resize it. + var old = areas.Areas[_dragIndex]; + var next = ToPicture(moved, picture); + areas.Set(_dragIndex, new Rect(next.X, next.Y, old.Width, old.Height)); + break; + } + default: + areas.Set(_dragIndex, ToPicture(Resize(_drag, _dragStartFrame, point - _dragStartPoint, picture), picture)); + break; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (_drag == Drag.None) + return; + // A click on the picture beside every frame: nothing selected. + if (_drag == Drag.Pending) + Areas?.Select(-1); + EndDrag(); + e.Pointer.Capture(null); + e.Handled = true; + PopHandle(_hoverHandle); + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + if (_drag != Drag.None) + { + EndDrag(); + PopHandle(Drag.None); + } + } + + private void EndDrag() + { + var wasDragging = _drag is not (Drag.None or Drag.Pending); + _drag = Drag.None; + _dragIndex = -1; + _snappedX = _snappedY = false; + if (wasDragging) + Areas?.End(); + InvalidateVisual(); + } + + private void CancelDrag() + { + if (_drag != Drag.None) + EndDrag(); + _hoverIndex = -1; + } + + /// The wheel grows or shrinks the selected frame about its centre. + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + base.OnPointerWheelChanged(e); + var picture = CalculateImageBounds(); + if (Areas is not { SelectedArea: { } selected } areas || picture.Width <= 0 || Math.Abs(e.Delta.Y) < 0.001 || _drag != Drag.None) + return; + var frame = ToScreen(selected, picture); + var factor = e.Delta.Y > 0 ? 1.05 : 0.95; + var w = SafeClamp(frame.Width * factor, MinSizePx, picture.Width); + var h = SafeClamp(frame.Height * factor, MinSizePx, picture.Height); + var x = SafeClamp(frame.Center.X - w / 2, picture.Left, picture.Right - w); + var y = SafeClamp(frame.Center.Y - h / 2, picture.Top, picture.Bottom - h); + areas.Set(areas.Selected, ToPicture(new Rect(x, y, w, h), picture)); + e.Handled = true; + } + + private static Point Clamp(Point point, Rect picture) => + new(Math.Clamp(point.X, picture.Left, picture.Right), Math.Clamp(point.Y, picture.Top, picture.Bottom)); + + private static double SafeClamp(double value, double min, double max) => + min > max ? Math.Clamp(value, max, min) : Math.Clamp(value, min, max); + + /// The frame between where a drag began and the pointer, at least , on the picture. + private static Rect FrameBetween(Point a, Point b, Rect picture) + { + var w = Math.Max(MinSizePx, Math.Abs(b.X - a.X)); + var h = Math.Max(MinSizePx, Math.Abs(b.Y - a.Y)); + var x = SafeClamp(b.X < a.X ? a.X - w : a.X, picture.Left, picture.Right - w); + var y = SafeClamp(b.Y < a.Y ? a.Y - h : a.Y, picture.Top, picture.Bottom - h); + return new Rect(x, y, w, h); + } + + /// A frame sized by one of its handles, its other sides staying put, within the picture. + private static Rect Resize(Drag handle, Rect start, Vector delta, Rect picture) + { + double left = start.Left, top = start.Top, right = start.Right, bottom = start.Bottom; + if (handle is Drag.TopLeft or Drag.Left or Drag.BottomLeft) + left = SafeClamp(start.Left + delta.X, picture.Left, right - MinSizePx); + if (handle is Drag.TopRight or Drag.Right or Drag.BottomRight) + right = SafeClamp(start.Right + delta.X, left + MinSizePx, picture.Right); + if (handle is Drag.TopLeft or Drag.Top or Drag.TopRight) + top = SafeClamp(start.Top + delta.Y, picture.Top, bottom - MinSizePx); + if (handle is Drag.BottomLeft or Drag.Bottom or Drag.BottomRight) + bottom = SafeClamp(start.Bottom + delta.Y, top + MinSizePx, picture.Bottom); + return new Rect(left, top, right - left, bottom - top); + } +} diff --git a/src/Filee.App/Services/HideService.cs b/src/Filee.App/Services/HideService.cs new file mode 100644 index 000000000..022c07359 --- /dev/null +++ b/src/Filee.App/Services/HideService.cs @@ -0,0 +1,93 @@ +// Pictures for the "Pixelate / blur" window: the preview, read as the image engine reads the file (so the frames land on +// the picture the conversion covers), and copies of it pixelated or blurred as chosen, which the frames show. + +using System.Runtime.InteropServices; +using Avalonia; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Engines.Magick; +using ImageMagick; + +namespace Filee.App.Services; + +public static class HideService +{ + /// Longest side of the preview: huge pictures are shown smaller, for a fluid window. + public const int MaxPreviewSide = 2560; + + /// True when the window can open a file: a picture the image engine reads. + public static bool CanOpen(string formatId) => + HideTool.CanHide(formatId) && ImageEncoder.Sources.Contains(formatId); + + /// + /// Reads as the conversion will (the composite of a PSD, the largest size of an icon, turned + /// as its EXIF orientation says) and makes a preview of it at most across. The size is + /// the picture's own. Animations and multi-page files are refused: the frames drawn on the first page would hide + /// nothing on the others. + /// + public static Task<(MagickImage Preview, int Width, int Height)> LoadPreviewAsync(string path, Preset tool) => + Task.Run(() => + { + var format = FormatRegistry.Detect(path)?.Id ?? throw new NotSupportedException("Unknown picture format."); + if (format == "png" && Apng.IsAnimated(path)) + throw new NotSupportedException("Animated PNG files can't have parts hidden."); + using var images = ImageReader.Read(path, format, tool); + var frames = ImageEncoder.SelectFrames(images, format, format); + if (frames.Count != 1) + throw new NotSupportedException("Animated or multi-page pictures can't have parts hidden."); + var preview = (MagickImage)frames[0].Clone(); + try + { + preview.AutoOrient(); + var (width, height) = ((int)preview.Width, (int)preview.Height); + if (preview.Width > MaxPreviewSide || preview.Height > MaxPreviewSide) + preview.Resize(new MagickGeometry(MaxPreviewSide, MaxPreviewSide) { Greater = true }); + preview.ResetPage(); + return (preview, width, height); + } + catch + { + preview.Dispose(); + throw; + } + }); + + /// + /// The size of the squares (or the blur) on the preview, for a picture of × + /// shown at pixels across: the conversion's, scaled. A square is a whole number of + /// the picture's pixels, as the conversion makes it. + /// + public static double PreviewEffectSize(HideStyle style, int strength, int width, int height, int previewWidth) + { + var size = HideTool.EffectSize(strength, width, height); + if (style == HideStyle.Pixelate) + size = Math.Round(size); + return size * previewWidth / Math.Max(1, width); + } + + /// A copy of covered all over: what a frame shows inside it. + public static Bitmap Cover(MagickImage preview, HideStyle style, double size) + { + using var copy = preview.Clone(); + AreaHider.Cover(copy, style, size); + return ToBitmap(copy); + } + + /// The picture as a bitmap the window draws. + public static Bitmap ToBitmap(IMagickImage image) + { + var pixels = image.ToByteArray(MagickFormat.Bgra); + var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned); + try + { + return new Bitmap(PixelFormat.Bgra8888, AlphaFormat.Unpremul, handle.AddrOfPinnedObject(), + new PixelSize((int)image.Width, (int)image.Height), new Vector(96, 96), (int)image.Width * 4); + } + finally + { + handle.Free(); + } + } +} diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs index b4ee3947b..6184fcb5c 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.IsHideTool) + return formats.Any(f => HideService.CanOpen(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 c6c238796..98dec6878 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 HideWindow? _hideWindow; internal ClipWindow? ClipWindow => _clipWindow; + internal HideWindow? HideWindow => _hideWindow; 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, pixelate / blur, 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.IsHideTool) + ShowHideWindow(files, preset, near); // 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,38 @@ public void ShowClipWindow(IReadOnlyList files, Preset preset, PixelPoin win.Activate(); } + /// + /// Opens the "Pixelate / blur" window on the pictures among the files. Each picture is written by an ordinary + /// conversion, shown in the toast. + /// + public void ShowHideWindow(IReadOnlyList files, Preset preset, PixelPoint? near = null) + { + var pictures = files.Where(f => FormatRegistry.Detect(f) is { } format && HideService.CanOpen(format.Id)).ToList(); + if (pictures.Count == 0) + return; + _hideWindow?.Close(); + var store = services.GetRequiredService(); + var catalog = services.GetRequiredService(); + var conversions = services.GetRequiredService(); + var log = services.GetService>(); + var planner = catalog.CreatePlanner(); + var vm = new HideWindowViewModel(pictures, preset, loc, store, + path => HideTool.TargetFor(FormatRegistry.Detect(path)?.Id ?? "", planner), + (path, conversion) => conversions.Start([path], conversion), log); + var win = new HideWindow { DataContext = vm }; + PrepareWindow(win); + win.PositionOnScreen(near); + win.Closed += (_, _) => + { + if (_hideWindow == win) + _hideWindow = null; + trimmer.Request(); + }; + _hideWindow = win; + win.Show(); + win.Activate(); + } + /// Opens the interactive crop overlay for the files. public void ShowCropOverlay(IReadOnlyList files, Preset preset, PixelPoint? near = null) { diff --git a/src/Filee.App/Styles/Icons.axaml b/src/Filee.App/Styles/Icons.axaml index c8c0588be..ac9152fe3 100644 --- a/src/Filee.App/Styles/Icons.axaml +++ b/src/Filee.App/Styles/Icons.axaml @@ -36,6 +36,8 @@ M12.5,8C9.85,8 7.45,9 5.6,10.6L2,7V16H11L7.38,12.38C8.77,11.22 10.54,10.5 12.5,10.5C16.04,10.5 19.05,12.81 20.1,16L22.47,15.22C21.08,11.03 17.15,8 12.5,8Z M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7Z M7,17V1H5V5H1V7H5V17A2,2 0 0,0 7,19H17V23H19V19H23V17M17,15H19V7C19,5.89 18.1,5 17,5H9V7H17V15Z + + M3,3H9V9H3Z M15,3H21V9H15Z M9,9H15V15H9Z M3,15H9V21H3Z M15,15H21V21H15Z M9.64,7.64C9.87,7.14 10,6.59 10,6A4,4 0 0,0 6,2A4,4 0 0,0 2,6A4,4 0 0,0 6,10C6.59,10 7.14,9.87 7.64,9.64L10,12L7.64,14.36C7.14,14.13 6.59,14 6,14A4,4 0 0,0 2,18A4,4 0 0,0 6,22A4,4 0 0,0 10,18C10,17.41 9.87,16.86 9.64,16.36L12,14L19,21H22V20L9.64,7.64M6,8A2,2 0 0,1 4,6A2,2 0 0,1 6,4A2,2 0 0,1 8,6A2,2 0 0,1 6,8M6,20A2,2 0 0,1 4,18A2,2 0 0,1 6,16A2,2 0 0,1 8,18A2,2 0 0,1 6,20M19,3L13,9L15,11L22,4V3H19Z M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z diff --git a/src/Filee.App/ViewModels/CropOverlayViewModel.cs b/src/Filee.App/ViewModels/CropOverlayViewModel.cs index 20a0c8c66..8a9ef5edd 100644 --- a/src/Filee.App/ViewModels/CropOverlayViewModel.cs +++ b/src/Filee.App/ViewModels/CropOverlayViewModel.cs @@ -520,8 +520,12 @@ public enum CropItemState Failed, } -/// One file of the crop overlay's strip: a small thumbnail once it has been opened, and how it went. -public sealed partial class CropQueueItem(ILocalizer loc, string name, int index) : ObservableObject, IDisposable +/// +/// One file of the strip of the crop overlay (and of the "Pixelate / blur" window): a small thumbnail once it has been +/// opened, and how it went. names a file that is done. +/// +public sealed partial class CropQueueItem(ILocalizer loc, string name, int index, string doneKey = "crop.state_done") + : ObservableObject, IDisposable { /// Longest side of a thumbnail in the strip (at twice its size on screen, for high-DPI displays). private const int ThumbnailSide = 72; @@ -553,7 +557,7 @@ public sealed partial class CropQueueItem(ILocalizer loc, string name, int index public string StateText => loc[State switch { CropItemState.Current => "crop.state_current", - CropItemState.Done => "crop.state_done", + CropItemState.Done => doneKey, CropItemState.Skipped => "crop.state_skipped", CropItemState.Failed => "crop.state_failed", _ => "crop.state_waiting", diff --git a/src/Filee.App/ViewModels/HideWindowViewModel.cs b/src/Filee.App/ViewModels/HideWindowViewModel.cs new file mode 100644 index 000000000..25e6cd244 --- /dev/null +++ b/src/Filee.App/ViewModels/HideWindowViewModel.cs @@ -0,0 +1,495 @@ +// State of the "Pixelate / blur" window: one picture at a time, as in the crop overlay, with frames drawn over the parts +// to hide, each showing them already pixelated or blurred; the choice of the two and how strongly (kept in the tool for +// the next time); and the conversion that writes the picture with those parts covered, which runs in the toast like +// any other. The frames carry on to the next picture of a batch (screenshots of one app put names in the same place). + +using System.Collections.ObjectModel; +using System.IO; +using Avalonia; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Filee.App.Controls; +using Filee.App.Services; +using Filee.Core.Localization; +using Filee.Core.Presets; +using Filee.Core.Settings; +using ImageMagick; +using Microsoft.Extensions.Logging; + +namespace Filee.App.ViewModels; + +public sealed partial class HideWindowViewModel : ObservableObject, IDisposable +{ + private readonly IReadOnlyList _files; + private readonly Preset _tool; + private readonly ILocalizer _loc; + private readonly UserDataStore _store; + private readonly Func _targetFor; + private readonly Action _export; + private readonly ILogger? _log; + + private MagickImage? _preview; + private int _coverVersion; + private int _coverShown; + private bool _closed; + + /// The frames of the last picture saved, for the next one. + private List _carried = []; + + [ObservableProperty] private int _currentIndex; + [ObservableProperty] private string _currentFileName = ""; + [ObservableProperty] private string _counterText = ""; + [ObservableProperty] private bool _hasMultipleFiles; + [ObservableProperty] private Bitmap? _previewImage; + [ObservableProperty] private Bitmap? _coverImage; + [ObservableProperty] private string _saveText = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PixelSize))] + private int _originalWidth; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(PixelSize))] + private int _originalHeight; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanSave), nameof(ShowEmptyHint))] + [NotifyCanExecuteChangedFor(nameof(SaveCommand))] + private bool _isBusy; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPixelate), nameof(IsBlur))] + private HideStyle _style; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StrengthText))] + private int _strength; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasStatusNotice))] + private string _statusNotice = ""; + + /// + /// All files are done and some could not be opened: the window stays open on and + /// until the user closes it. + /// + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsEditing), nameof(CanSave), nameof(ShowEmptyHint))] + [NotifyCanExecuteChangedFor(nameof(SaveCommand))] + private bool _isFinished; + + [ObservableProperty] private string _summary = ""; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasDone))] + private int _doneCount; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasSkipped))] + private int _skippedCount; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasFailed))] + private int _failedCount; + + /// The parts of the current picture to hide. + public HideAreaSet Areas { get; } = new(); + + /// "file: reason" for each file that could not be opened. + public ObservableCollection Problems { get; } = []; + + /// Every file of the run with how it went, for the strip of thumbnails. + public ObservableCollection Queue { get; } = []; + + public PixelSize PixelSize => new(OriginalWidth, OriginalHeight); + public bool IsEditing => !IsFinished; + public bool HasStatusNotice => !string.IsNullOrWhiteSpace(StatusNotice); + public bool HasDone => DoneCount > 0; + public bool HasSkipped => SkippedCount > 0; + public bool HasFailed => FailedCount > 0; + public bool HasAreas => Areas.Count > 0; + public bool HasSelection => Areas.Selected >= 0; + public bool CanUndo => Areas.CanUndo; + public bool CanRedo => Areas.CanRedo; + + /// Saving needs a part to hide: without one the copy would only be the picture again. + public bool CanSave => IsEditing && !IsBusy && HasAreas; + + /// "Drag over what you want to hide", on a picture without frames. + public bool ShowEmptyHint => IsEditing && !IsBusy && !HasAreas && PreviewImage is not null; + + public bool IsPixelate + { + get => Style == HideStyle.Pixelate; + set + { + if (value) + Style = HideStyle.Pixelate; + } + } + + public bool IsBlur + { + get => Style == HideStyle.Blur; + set + { + if (value) + Style = HideStyle.Blur; + } + } + + /// "Strength: 5", for the slider's tooltip and screen readers. + public string StrengthText => _loc.Format("hide.strength_value", Strength); + + public int MinStrength => HideTool.MinStrength; + public int MaxStrength => HideTool.MaxStrength; + + public event Action? CloseRequested; + + /// The pictures, one after the other (other files are left out by the caller). + /// The "Pixelate / blur" tool: the last choice, its name and output rule. + /// The format a picture at a path is written in (). + /// Starts the conversion that writes a picture (the file, the conversion's preset). + public HideWindowViewModel(IReadOnlyList files, Preset tool, ILocalizer loc, UserDataStore store, + Func targetFor, Action export, ILogger? log = null) + { + _files = files; + _tool = tool; + _loc = loc; + _store = store; + _targetFor = targetFor; + _export = export; + _log = log; + _style = tool.Image.HideStyle; + _strength = HideTool.ClampStrength(tool.Image.HideStrength); + _hasMultipleFiles = files.Count > 1; + for (var i = 0; i < files.Count; i++) + Queue.Add(new CropQueueItem(loc, Path.GetFileName(files[i]), i, "hide.state_done")); + Areas.Changed += OnAreasChanged; + loc.LanguageChanged += OnLanguageChanged; + _ = LoadFileAsync(0); + } + + partial void OnStyleChanged(HideStyle value) => RefreshCover(); + + partial void OnStrengthChanged(int value) + { + var clamped = HideTool.ClampStrength(value); + if (clamped != value) + { + Strength = clamped; + return; + } + RefreshCover(); + } + + partial void OnPreviewImageChanged(Bitmap? value) => OnPropertyChanged(nameof(ShowEmptyHint)); + + private void OnAreasChanged() + { + OnPropertyChanged(nameof(HasAreas)); + OnPropertyChanged(nameof(HasSelection)); + OnPropertyChanged(nameof(CanUndo)); + OnPropertyChanged(nameof(CanRedo)); + OnPropertyChanged(nameof(CanSave)); + OnPropertyChanged(nameof(ShowEmptyHint)); + SaveCommand.NotifyCanExecuteChanged(); + } + + /// Opens picture ; past the last one, the end. + public async Task LoadFileAsync(int index) + { + if (_closed) + return; + if (index < 0 || index >= _files.Count) + { + Finish(); + return; + } + + IsBusy = true; + CurrentIndex = index; + var path = _files[index]; + CurrentFileName = Path.GetFileName(path); + CounterText = _loc.Format("crop.page_counter", index + 1, _files.Count); + SaveText = _loc[index < _files.Count - 1 ? "hide.save_next" : "hide.save"]; + Queue[index].State = CropItemState.Current; + + try + { + var (preview, width, height) = await HideService.LoadPreviewAsync(path, _tool); + var bitmap = await Task.Run(() => HideService.ToBitmap(preview)); + if (_closed) + { + preview.Dispose(); + bitmap.Dispose(); + return; + } + ReplacePreview(preview); + var old = PreviewImage; + OriginalWidth = width; + OriginalHeight = height; + PreviewImage = bitmap; + old?.Dispose(); + Queue[index].SetThumbnail(bitmap); + Areas.Reset(_carried); + RefreshCover(); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogInformation(ex, "Could not open {File} for hiding parts", path); + if (_closed) + return; + // The animated / multi-page check throws NotSupportedException: its English message stays in the log. + var unsupported = ex is NotSupportedException; + AddProblem(path, unsupported ? "error.unsupported_source" : "error.conversion_failed"); + Queue[index].State = CropItemState.Failed; + IsBusy = false; + await LoadFileAsync(index + 1); + return; + } + finally + { + if (!_closed && CurrentIndex == index) + IsBusy = false; + } + } + + /// + /// Covers the preview again as chosen, off the window's thread: the frames show the picture as it is until then + /// (softly blurred). Only the newest result is kept, so dragging the strength never queues up work. + /// + private void RefreshCover() + { + if (_preview is not { } preview || _closed) + return; + var version = ++_coverVersion; + var style = Style; + var size = HideService.PreviewEffectSize(style, Strength, OriginalWidth, OriginalHeight, (int)preview.Width); + _ = Task.Run(() => + { + Bitmap? cover = null; + try + { + lock (preview) + { + // A newer choice, or another picture (this one disposed): nothing to do. + if (version != Volatile.Read(ref _coverVersion) || !ReferenceEquals(preview, Volatile.Read(ref _preview))) + return; + cover = HideService.Cover(preview, style, size); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogDebug(ex, "Could not cover the preview"); + return; + } + Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + if (version != _coverVersion || _closed || !ReferenceEquals(preview, _preview)) + { + cover.Dispose(); + return; + } + var old = CoverImage; + CoverImage = cover; + _coverShown = version; + old?.Dispose(); + }); + }); + } + + /// Waits for the covered preview of the current choice (tests capture it). + internal bool IsCoverCurrent => CoverImage is not null && _coverShown == _coverVersion; + + private void ReplacePreview(MagickImage? preview) + { + var old = _preview; + _preview = preview; + if (old is not null) + { + lock (old) + old.Dispose(); + } + var cover = CoverImage; + CoverImage = null; + cover?.Dispose(); + } + + // ───────────────────────── Commands ───────────────────────── + + /// + /// Starts the conversion that writes the picture with its parts covered (its progress shows in the toast), keeps the + /// choice in the tool, then opens the next picture or closes. + /// + [RelayCommand(CanExecute = nameof(CanSave))] + public async Task SaveAsync() + { + if (!CanSave || _closed || CurrentIndex >= _files.Count) + return; + var path = _files[CurrentIndex]; + var areas = Areas.Areas.Select(a => new HiddenArea { X = a.X, Y = a.Y, Width = a.Width, Height = a.Height }).ToList(); + Remember(); + try + { + _export(path, HideTool.ConversionOf(_tool, _targetFor(path), areas)); + Queue[CurrentIndex].State = CropItemState.Done; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogError(ex, "Could not start hiding parts of {File}", path); + AddProblem(path, "error.conversion_failed"); + Queue[CurrentIndex].State = CropItemState.Failed; + } + _carried = [.. Areas.Areas]; + await LoadFileAsync(CurrentIndex + 1); + } + + /// Leaves this picture as it is and opens the next one. + [RelayCommand] + public async Task SkipAsync() + { + if (IsBusy || IsFinished || _closed) + return; + Queue[CurrentIndex].State = CropItemState.Skipped; + // The frames stay for the next picture, as drawn. + _carried = [.. Areas.Areas]; + await LoadFileAsync(CurrentIndex + 1); + } + + /// Close (Esc). Pictures already started go on in the toast. + [RelayCommand] + public void Cancel() + { + Shutdown(); + CloseRequested?.Invoke(); + } + + [RelayCommand] + public void SetStyle(HideStyle style) + { + if (IsEditing) + Style = style; + } + + /// Removes every frame (R); Ctrl+Z brings them back. + [RelayCommand] + public void Clear() + { + if (IsEditing && !IsBusy) + Areas.Clear(); + } + + [RelayCommand] + public void RemoveSelected() + { + if (IsEditing && !IsBusy) + Areas.RemoveSelected(); + } + + [RelayCommand] + public void Undo() + { + if (IsEditing && !IsBusy) + Areas.Undo(); + } + + [RelayCommand] + public void Redo() + { + if (IsEditing && !IsBusy) + Areas.Redo(); + } + + /// A step stronger or weaker (+ / −). + public void StepStrength(int step) + { + if (IsEditing) + Strength = HideTool.ClampStrength(Strength + step); + } + + // ───────────────────────── Files, messages and the end ───────────────────────── + + /// Keeps pixelate or blur and the strength in the tool, as saved and as given to this window. + private void Remember() + { + HideTool.Remember(_tool, Style, Strength); + if (_store.FindPreset(_tool.Id) is not { IsHideTool: true } saved + || (saved.Image.HideStyle == Style && saved.Image.HideStrength == Strength)) + return; + HideTool.Remember(saved, Style, Strength); + try + { + _store.SaveLibrary(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log?.LogWarning(ex, "Could not remember how parts are hidden"); + } + } + + private void AddProblem(string path, string key) + { + var line = $"{Path.GetFileName(path)}: {_loc[key]}"; + Problems.Add(line); + StatusNotice = line; + } + + /// Closes after the last picture, or stays open on the summary when a picture could not be opened. + private void Finish() + { + if (Problems.Count == 0) + { + Cancel(); + return; + } + Summary = _loc.Format("toast.done_errors", Problems.Count, Queue.Count); + DoneCount = Queue.Count(q => q.State == CropItemState.Done); + SkippedCount = Queue.Count(q => q.State == CropItemState.Skipped); + FailedCount = Queue.Count(q => q.State == CropItemState.Failed); + StatusNotice = ""; + HasMultipleFiles = false; + IsBusy = false; + Areas.Reset([]); + ReplacePreview(null); + var old = PreviewImage; + PreviewImage = null; + old?.Dispose(); + IsFinished = true; + } + + private void Shutdown() + { + if (_closed) + return; + // A choice made without saving is kept too: the window opens with it next time. + if (IsEditing && PreviewImage is not null) + Remember(); + _closed = true; + } + + private void OnLanguageChanged(object? sender, EventArgs e) + { + if (!IsFinished && CurrentIndex < _files.Count) + { + CounterText = _loc.Format("crop.page_counter", CurrentIndex + 1, _files.Count); + SaveText = _loc[CurrentIndex < _files.Count - 1 ? "hide.save_next" : "hide.save"]; + } + OnPropertyChanged(nameof(StrengthText)); + foreach (var item in Queue) + item.RefreshTexts(); + } + + public void Dispose() + { + Shutdown(); + _loc.LanguageChanged -= OnLanguageChanged; + Areas.Changed -= OnAreasChanged; + ReplacePreview(null); + PreviewImage?.Dispose(); + PreviewImage = null; + foreach (var item in Queue) + item.Dispose(); + } +} diff --git a/src/Filee.App/Views/HideWindow.axaml b/src/Filee.App/Views/HideWindow.axaml new file mode 100644 index 000000000..f46641eda --- /dev/null +++ b/src/Filee.App/Views/HideWindow.axaml @@ -0,0 +1,430 @@ + + + + + M12,2.6C12.3,2.6 12.55,2.73 12.74,2.96C14.9,5.6 19,10.4 19,14.2A7,7 0 0,1 5,14.2C5,10.4 9.1,5.6 11.26,2.96A0.95,0.95 0 0,1 12,2.6Z + M11.5,8C14.15,8 16.55,9 18.4,10.6L22,7V16H13L16.62,12.38C15.23,11.22 13.46,10.5 11.5,10.5C7.96,10.5 4.95,12.81 3.9,16L1.53,15.22C2.92,11.03 6.85,8 11.5,8Z + M4 7a3 3 0 0 1 3-3h10a3 3 0 0 1 3 3v10a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3z M7 6.2a.8.8 0 0 0-.8.8v10c0 .44.36.8.8.8h10a.8.8 0 0 0 .8-.8V7a.8.8 0 0 0-.8-.8z + M18 10a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3z M18 16a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3z M12 16a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +