diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 736a8dfd..17464aea 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 3b796162..384a2e14 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 بجانبها)",
@@ -253,6 +254,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": "النهاية",
@@ -338,6 +356,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": "اقتصاص",
@@ -385,6 +413,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 f0767159..6b0f62ea 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)",
@@ -253,6 +254,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",
@@ -338,6 +356,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",
@@ -385,6 +413,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 ef7b293f..bf28b748 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)",
@@ -262,6 +263,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",
@@ -348,6 +366,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",
@@ -395,6 +423,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 b6b294c4..7ef306f5 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)",
@@ -253,6 +254,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",
@@ -338,6 +356,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",
@@ -385,6 +413,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 4e85b4ca..f329c4f4 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é)",
@@ -262,6 +263,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",
@@ -348,6 +366,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",
@@ -395,6 +423,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 0467d2b3..6d87f7cb 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 लाइव फ़ोटो)",
@@ -253,6 +254,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": "अंत",
@@ -338,6 +356,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": "क्रॉप",
@@ -385,6 +413,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 92037f1d..547b261b 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)",
@@ -253,6 +254,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": "終了",
@@ -338,6 +356,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": "トリミング",
@@ -385,6 +413,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 2a07274e..2098d6a6 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가 있는 아이폰 라이브 포토)",
@@ -262,6 +263,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": "끝",
@@ -348,6 +366,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": "자르기",
@@ -395,6 +423,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 007ea0a2..0a38f331 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 लाइभ फोटो)",
@@ -253,6 +254,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": "अन्त्य",
@@ -338,6 +356,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": "क्रप",
@@ -385,6 +413,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 2ea2ddd2..c4e18c3c 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)",
@@ -262,6 +263,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",
@@ -348,6 +366,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",
@@ -395,6 +423,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 8719a456..96fefb71 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)",
@@ -263,6 +264,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",
@@ -348,6 +366,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",
@@ -395,6 +423,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 67c2a047..d9784665 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)",
@@ -262,6 +263,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",
@@ -348,6 +366,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",
@@ -395,6 +423,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 7e6e77dc..e7548813 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 рядом)",
@@ -262,6 +263,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": "Конец",
@@ -348,6 +366,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": "Обрезать",
@@ -395,6 +423,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 5a3466a9..12e899c4 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 อยู่คู่กัน)",
@@ -253,6 +254,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": "สิ้นสุด",
@@ -338,6 +356,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": "ครอบตัด",
@@ -385,6 +413,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 59d187aa..96f6dc4e 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)",
@@ -253,6 +254,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ş",
@@ -338,6 +356,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",
@@ -385,6 +413,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 b75eec28..46155fad 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 实况照片)",
@@ -262,6 +263,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": "结束",
@@ -348,6 +366,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": "裁剪",
@@ -395,6 +423,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 c2c7027d..3b801805 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 原況照片)",
@@ -262,6 +263,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": "結束",
@@ -348,6 +366,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": "裁剪",
@@ -395,6 +423,7 @@
"preset.merge_videos": "合併影片",
"preset.compress_audio": "壓縮音訊",
"preset.merge_audio": "合併音訊",
+ "preset.merge_images": "合併圖片",
"preset.remove_metadata": "移除中繼資料",
"format.folder": "資料夾",
diff --git a/src/Filee.App/Controls/CropCanvas.cs b/src/Filee.App/Controls/CropCanvas.cs
index 9b7cfad6..3b0f358b 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 00000000..eea6b497
--- /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 00000000..89a69418
--- /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 00000000..022c0735
--- /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 1763d469..f2451407 100644
--- a/src/Filee.App/Services/PresetAvailability.cs
+++ b/src/Filee.App/Services/PresetAvailability.cs
@@ -124,6 +124,10 @@ private PresetJudgement Evaluate(Preset preset, IReadOnlyList format
return formats.Any(f => ImageEditTool.CanEdit(f.Id))
? new(PresetUse.Usable, null)
: new(PresetUse.Unusable, _loc["error.images_only"]);
+ 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)
{
@@ -174,6 +178,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 d689f25d..943831e6 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 f9acdb40..58e65ca1 100644
--- a/src/Filee.App/Services/WindowService.cs
+++ b/src/Filee.App/Services/WindowService.cs
@@ -36,9 +36,11 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem
private CropOverlayWindow? _cropOverlay;
private ClipWindow? _clipWindow;
+ private HideWindow? _hideWindow;
private ImageEditWindow? _imageEdit;
internal ClipWindow? ClipWindow => _clipWindow;
+ internal HideWindow? HideWindow => _hideWindow;
internal ImageEditWindow? ImageEdit => _imageEdit;
private readonly HashSet _spreadsheetStudios = [];
@@ -51,11 +53,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 += (_, _) =>
@@ -67,6 +82,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;
}
@@ -123,13 +140,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, edit pictures, the spreadsheet print studio: one per workbook,
- /// the PDF page editor) on the files. A tool this version doesn't know opens nothing.
+ /// Opens the window of a tool preset (crop, cut a clip, edit pictures, 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);
else if (preset.IsImageEditTool)
ShowImageEditWindow(files, preset);
// Before the crop: the print studio and the PDF page tools have no Tool of their own, which reads as a crop
@@ -208,6 +227,38 @@ public void ShowImageEditWindow(IReadOnlyList files, Preset preset)
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();
+ }
+
///
/// The pixel size of a picture as it is seen, read from its headers: turned as its EXIF orientation says, and the
/// largest of an icon's sizes (the one converted). Null when ImageMagick can't read it.
diff --git a/src/Filee.App/Styles/Icons.axaml b/src/Filee.App/Styles/Icons.axaml
index c8c0588b..ac9152fe 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,8ZM12,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,7ZM7,17V1H5V5H1V7H5V17A2,2 0 0,0 7,19H17V23H19V19H23V17M17,15H19V7C19,5.89 18.1,5 17,5H9V7H17V15Z
+
+ M3,3H9V9H3Z M15,3H21V9H15Z M9,9H15V15H9Z M3,15H9V21H3Z M15,15H21V21H15ZM9.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,4V3H19ZM14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20ZM14,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 20a0c8c6..8a9ef5ed 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 00000000..25e6cd24
--- /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/ViewModels/MergeOrderViewModel.cs b/src/Filee.App/ViewModels/MergeOrderViewModel.cs
index 0cf1a743..7710534b 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/HideWindow.axaml b/src/Filee.App/Views/HideWindow.axaml
new file mode 100644
index 00000000..bc5430a2
--- /dev/null
+++ b/src/Filee.App/Views/HideWindow.axaml
@@ -0,0 +1,431 @@
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/HideWindow.axaml.cs b/src/Filee.App/Views/HideWindow.axaml.cs
new file mode 100644
index 00000000..b625c7d3
--- /dev/null
+++ b/src/Filee.App/Views/HideWindow.axaml.cs
@@ -0,0 +1,286 @@
+using System.ComponentModel;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using Filee.App.Controls;
+using Filee.App.Services;
+using Filee.App.ViewModels;
+using Filee.Core.Presets;
+
+namespace Filee.App.Views;
+
+///
+/// The "Pixelate / blur" window: placement, keys (Enter saves, Esc closes, Delete removes the selected frame, Tab and
+/// Shift+Tab go from frame to frame, the arrow keys move it (Shift × 10), R clears every frame, P pixelates, B blurs,
+/// + and − change the strength, Ctrl+Z / Ctrl+Y undo and redo) and moving, sizing and maximizing the frameless window,
+/// as the crop overlay's.
+///
+public partial class HideWindow : Window
+{
+ private HideWindowViewModel? _vm;
+
+ /// The focus came from the keyboard (Tab, arrows), not from a click.
+ private bool _focusByKeyboard;
+
+ public HideWindow()
+ {
+ InitializeComponent();
+ Motion.Track(this);
+ Canvas.PlayAnimations = Motion.Enabled;
+ Motion.Changed += OnMotionChanged;
+ DataContextChanged += OnDataContextChanged;
+ AddHandler(GotFocusEvent, (_, e) => _focusByKeyboard = e.NavigationMethod is NavigationMethod.Tab or NavigationMethod.Directional,
+ RoutingStrategies.Bubble, handledEventsToo: true);
+ AddHandler(KeyDownEvent, OnKeyDownFirst, RoutingStrategies.Tunnel);
+ // Enter is the window's own (save), so a button that was clicked can't be pressed by it later; nor by its key-up.
+ AddHandler(KeyUpEvent, (_, e) =>
+ {
+ if (e.Key is Key.Enter or Key.Space && !ControlTakes(e))
+ e.Handled = true;
+ }, RoutingStrategies.Tunnel);
+ ResizeGrip.PointerPressed += OnGripPressed;
+ Stage.SizeChanged += (_, _) => FitToolbar();
+ Closed += (_, _) => Motion.Changed -= OnMotionChanged;
+ }
+
+ ///
+ /// How much of the toolbar's names it gives up, in turn, until it fits the window: none; tight drops the names
+ /// of Clear all and Skip and the keycaps (the tooltips and the line of keys below keep them); compact also
+ /// the names of Pixelate and Blur, with a shorter strength slider.
+ ///
+ internal static readonly string?[] ToolbarLevels = [null, "tight", "compact"];
+
+ /// The level in use (see ).
+ internal string? ToolbarLevel { get; private set; }
+
+ /// Room for the toolbar: the stage less the pill's margin.
+ internal double ToolbarRoom => Stage.Bounds.Width - 32;
+
+ ///
+ /// The width the toolbar asks for at , measured with the fonts in use (they differ from one
+ /// system to another, so no width is assumed).
+ ///
+ internal double ToolbarWidthAt(string? level)
+ {
+ SetToolbarLevel(level);
+ // Measured afresh all the way down: a button keeps the size it measured before its name was hidden or shown.
+ foreach (var part in ToolbarContent.GetVisualDescendants().OfType())
+ part.InvalidateMeasure();
+ ToolbarContent.InvalidateMeasure();
+ ToolbarContent.Measure(Size.Infinity);
+ return ToolbarContent.DesiredSize.Width + Toolbar.Padding.Left + Toolbar.Padding.Right + Toolbar.BorderThickness.Left
+ + Toolbar.BorderThickness.Right;
+ }
+
+ /// The first level, from all the names to icons only, at which the toolbar fits the window.
+ internal void FitToolbar()
+ {
+ var room = ToolbarRoom;
+ if (room <= 0)
+ return;
+ foreach (var level in ToolbarLevels)
+ {
+ if (level == ToolbarLevels[^1] || ToolbarWidthAt(level) <= room)
+ {
+ SetToolbarLevel(level);
+ return;
+ }
+ }
+ }
+
+ private void SetToolbarLevel(string? level)
+ {
+ ToolbarLevel = level;
+ foreach (var each in ToolbarLevels.OfType())
+ Classes.Set(each, each == level || (each == "tight" && level == "compact"));
+ }
+
+ /// The names changed (language, "Hide & Next" → "Hide & Save", Skip shown): fitted again once they show.
+ private void RefitToolbar() => Dispatcher.UIThread.Post(FitToolbar, DispatcherPriority.Render);
+
+ internal HideCanvas HideCanvas => Canvas;
+
+ ///
+ /// Opens near (the screen of the donut), else on the primary screen, centred in its working
+ /// area and no larger than most of it.
+ ///
+ public void PositionOnScreen(PixelPoint? near = null)
+ {
+ var screen = (near.HasValue ? Screens.ScreenFromPoint(near.Value) : null)
+ ?? Screens.ScreenFromPoint(Screens.Primary?.Bounds.Center ?? default)
+ ?? Screens.Primary;
+ if (screen is null)
+ return;
+ var scaling = screen.Scaling;
+ var area = screen.WorkingArea;
+ Width = Math.Max(MinWidth, Math.Min(Width, area.Width / scaling * 0.94));
+ Height = Math.Max(MinHeight, Math.Min(Height, area.Height / scaling * 0.92));
+ var x = area.X + Math.Max(0, (area.Width - (int)(Width * scaling)) / 2);
+ var y = area.Y + Math.Max(0, (area.Height - (int)(Height * scaling)) / 2);
+ Position = new PixelPoint(x, y);
+ }
+
+ /// Settles every animation and transition (tests capture the end state).
+ internal void SettleForCapture()
+ {
+ Classes.Set("reduced-motion", true);
+ Canvas.PlayAnimations = false;
+ Canvas.FinishAnimations();
+ }
+
+ private void OnDataContextChanged(object? sender, EventArgs e)
+ {
+ if (DataContext is HideWindowViewModel vm && !ReferenceEquals(vm, _vm))
+ {
+ _vm = vm;
+ vm.CloseRequested += Close;
+ vm.PropertyChanged += OnViewModelChanged;
+ Closed += (_, _) => vm.Dispose();
+ }
+ }
+
+ private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (_vm is null)
+ return;
+ if (e.PropertyName == nameof(HideWindowViewModel.CurrentIndex))
+ Dispatcher.UIThread.Post(() => Strip.ContainerFromIndex(_vm.CurrentIndex)?.BringIntoView(), DispatcherPriority.Background);
+ else if (e.PropertyName is nameof(HideWindowViewModel.SaveText) or nameof(HideWindowViewModel.HasMultipleFiles))
+ RefitToolbar();
+ }
+
+ private void OnMotionChanged(object? sender, EventArgs e) => Canvas.PlayAnimations = Motion.Enabled;
+
+ // ───────────────────────── Keys ─────────────────────────
+
+ ///
+ /// The control that has the focus, reached with the keyboard, takes 's key as it would anywhere
+ /// else: Space and Enter press a button, the arrows move the strength slider.
+ ///
+ private bool ControlTakes(KeyEventArgs e)
+ {
+ if (!_focusByKeyboard || e.KeyModifiers != KeyModifiers.None)
+ return false;
+ return FocusManager?.GetFocusedElement() switch
+ {
+ Button => e.Key is Key.Space or Key.Enter,
+ Slider => e.Key is Key.Left or Key.Right or Key.Up or Key.Down or Key.Home or Key.End or Key.PageUp or Key.PageDown,
+ _ => false,
+ };
+ }
+
+ /// Before any focused control: the window's keys, unless a control reached with the keyboard takes it.
+ private void OnKeyDownFirst(object? sender, KeyEventArgs e)
+ {
+ if (_vm is not { } vm || ControlTakes(e))
+ return;
+ var plain = e.KeyModifiers == KeyModifiers.None;
+ var command = e.KeyModifiers == KeyModifiers.Control || e.KeyModifiers == KeyModifiers.Meta;
+ var commandShift = e.KeyModifiers == (KeyModifiers.Control | KeyModifiers.Shift) || e.KeyModifiers == (KeyModifiers.Meta | KeyModifiers.Shift);
+ var editing = vm.IsEditing && !vm.IsBusy;
+ switch (e.Key)
+ {
+ case Key.Escape:
+ vm.Cancel();
+ e.Handled = true;
+ break;
+ case Key.Enter:
+ if (vm.IsFinished)
+ vm.Cancel();
+ else
+ _ = vm.SaveAsync();
+ e.Handled = true;
+ break;
+ case Key.Delete or Key.Back when plain && editing:
+ vm.RemoveSelected();
+ e.Handled = true;
+ break;
+ // Tab goes from frame to frame while there are frames; without, it moves the focus as usual.
+ case Key.Tab when editing && vm.HasAreas && (plain || e.KeyModifiers == KeyModifiers.Shift):
+ vm.Areas.SelectNext(e.KeyModifiers == KeyModifiers.Shift ? -1 : 1);
+ e.Handled = true;
+ break;
+ case Key.Z when command && editing:
+ vm.Undo();
+ e.Handled = true;
+ break;
+ case Key.Y when command && editing:
+ case Key.Z when commandShift && editing:
+ vm.Redo();
+ e.Handled = true;
+ break;
+ case Key.R when plain && editing:
+ vm.Clear();
+ e.Handled = true;
+ break;
+ case Key.P when plain && editing:
+ vm.SetStyle(HideStyle.Pixelate);
+ e.Handled = true;
+ break;
+ case Key.B when plain && editing:
+ vm.SetStyle(HideStyle.Blur);
+ e.Handled = true;
+ break;
+ case Key.OemPlus or Key.Add when (plain || e.KeyModifiers == KeyModifiers.Shift) && editing:
+ vm.StepStrength(1);
+ e.Handled = true;
+ break;
+ case Key.OemMinus or Key.Subtract when plain && editing:
+ vm.StepStrength(-1);
+ e.Handled = true;
+ break;
+ // The picture is never mirrored, also in right-to-left languages: Left is always left.
+ case Key.Left or Key.Right or Key.Up or Key.Down when editing && vm.HasSelection
+ && (e.KeyModifiers & ~KeyModifiers.Shift) == KeyModifiers.None:
+ var step = e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? 10 : 1;
+ vm.Areas.Nudge(e.Key switch { Key.Left => -step, Key.Right => step, _ => 0 },
+ e.Key switch { Key.Up => -step, Key.Down => step, _ => 0 }, vm.OriginalWidth, vm.OriginalHeight);
+ e.Handled = true;
+ break;
+ }
+ }
+
+ // ───────────────────────── Moving and sizing the window ─────────────────────────
+
+ /// The stage beside the picture moves the window; a double click maximizes it.
+ protected override void OnPointerPressed(PointerPressedEventArgs e)
+ {
+ base.OnPointerPressed(e);
+ if (e.Handled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ return;
+ if (e.ClickCount == 2)
+ ToggleMaximized();
+ else if (WindowState == WindowState.Normal)
+ BeginMoveDrag(e);
+ }
+
+ private void OnGripPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (WindowState == WindowState.Normal && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
+ BeginResizeDrag(WindowEdge.SouthEast, e);
+ e.Handled = true;
+ }
+
+ private void OnMaximize(object? sender, RoutedEventArgs e) => ToggleMaximized();
+
+ private void ToggleMaximized() =>
+ WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+ if (change.Property == WindowStateProperty)
+ {
+ var maximized = WindowState == WindowState.Maximized;
+ Classes.Set("maximized", maximized);
+ ResizeGrip.IsVisible = !maximized;
+ var key = maximized ? "crop.restore" : "crop.maximize";
+ MaximizeButton.Bind(ToolTip.TipProperty, MaximizeButton.GetResourceObservable(key));
+ MaximizeButton.Bind(Avalonia.Automation.AutomationProperties.NameProperty, MaximizeButton.GetResourceObservable(key));
+ }
+ }
+}
diff --git a/src/Filee.App/Views/MergeOrderWindow.axaml b/src/Filee.App/Views/MergeOrderWindow.axaml
index aa913b68..5f5cb502 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 1d835c29..7dfbce73 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 f826f669..53e9b84b 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)
@@ -265,8 +269,8 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count),
// one whose text is recognized first.
if (FormatRegistry.Detect(file.SourcePath)?.Id == "pdf" && !IsEmptyFile(file.SourcePath) && !job.Preset.RecognizesTextOf("pdf"))
{
- // Already a PDF, merged as it is: its pages can't be turned (only images going into it can).
- if (job.Preset.Image.TurnsOrMirrors)
+ // Already a PDF, merged as it is: its pages can't be turned or covered (only images going into it can).
+ if (job.Preset.Image.ReworksPixels)
FailUnturned(job, file, "pdf → pdf");
else
parts[i] = file.SourcePath;
@@ -323,8 +327,8 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count),
var file = job.Files[i];
if (Stopped(file))
return;
- // A TIFF joins as it is, unless it is to be turned: then it goes through the engine like the rest.
- parts[i] = FormatRegistry.Detect(file.SourcePath)?.Id == "tiff" && !job.Preset.Image.TurnsOrMirrors
+ // A TIFF joins as it is, unless it is to be turned or covered: then it goes through the engine like the rest.
+ parts[i] = FormatRegistry.Detect(file.SourcePath)?.Id == "tiff" && !job.Preset.Image.ReworksPixels
&& !IsEmptyFile(file.SourcePath)
? [file.SourcePath]
: await RunFileAsync(job, file, i + 1, planner, workDir, finalOutput: false, intermediate: "tiff");
@@ -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":
@@ -403,10 +410,10 @@ await Parallel.ForEachAsync(Enumerable.Range(0, job.Files.Count),
}
if (slideshow)
{
- parts[i] = source.Id == "png" && !job.Preset.Image.TurnsOrMirrors
+ parts[i] = source.Id == "png" && !job.Preset.Image.ReworksPixels
? [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);
}
@@ -500,11 +571,11 @@ private void FailFile(ConversionJob job, FileResult file, string key, string? de
Notify(job);
}
- /// A file that would be used as it is although the preset turns or mirrors it.
+ /// A file that would be used as it is although the preset turns, mirrors or covers parts of it.
private void FailUnturned(ConversionJob job, FileResult file, string detail)
{
file.State = FileState.Failed;
- file.ErrorKey = "error.cannot_turn";
+ file.ErrorKey = job.Preset.Image.HidesAreas ? "error.cannot_hide" : "error.cannot_turn";
file.ErrorDetail = detail;
file.Progress = 1;
Notify(job);
@@ -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,11 @@ 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)
- return Fail(file, "error.cannot_turn", $"{source.Id} → {target}");
- var unturned = turnAt < 0 ? job.Preset : Unturned(job.Preset);
+ // Parts of a picture to hide likewise, once: a route that can't cover them must not write the picture uncovered.
+ var turnAt = runWith.Image.ReworksPixels ? route.FirstTransformStep() : -1;
+ if (runWith.Image.ReworksPixels && turnAt < 0)
+ return Fail(file, runWith.Image.HidesAreas ? "error.cannot_hide" : "error.cannot_turn", $"{source.Id} → {target}");
+ var unturned = turnAt < 0 ? runWith : Unturned(runWith);
file.State = FileState.Running;
Notify(job);
@@ -739,7 +813,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];
@@ -767,9 +841,10 @@ private async Task> RunFileAsync(
await gate.WaitAsync(ct);
try
{
- // 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;
+ // Turned (and covered) once, in step turnAt: every other step gets the preset without rotation,
+ // flip and hidden parts, also one before it whose engine applies image options but doesn't say
+ // it turns.
+ 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 +872,17 @@ 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]);
+ // Never with hidden parts: the source copied over the result would show them again.
+ var copiedSource = finalOutput && runWith.Output.OnlyIfSmaller && !runWith.Image.HidesAreas && 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 +891,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)];
@@ -856,6 +933,7 @@ static Preset Unturned(Preset preset)
var copy = preset.Clone();
copy.Image.Rotation = ImageRotation.None;
copy.Image.Flip = ImageFlip.None;
+ copy.Image.HiddenAreas = null;
return copy;
}
diff --git a/src/Filee.Core/History/HistoryEntry.cs b/src/Filee.Core/History/HistoryEntry.cs
index 5a606c2e..9efa654a 100644
--- a/src/Filee.Core/History/HistoryEntry.cs
+++ b/src/Filee.Core/History/HistoryEntry.cs
@@ -61,7 +61,8 @@ public static HistoryEntry From(ConversionJob job)
Outputs = [.. job.Outputs],
Failures = failures,
// history.json isn't the place for a PDF password: a retry takes it from the preset again.
- Preset = failures.Count > 0 ? job.Preset.Clone().WithoutPassword(mark: true) : null,
+ // No Retry for parts of a picture to hide: a Filee from before them would write the picture as it is.
+ Preset = failures.Count > 0 && !job.Preset.Image.HidesAreas ? job.Preset.Clone().WithoutPassword(mark: true) : null,
Undoable = UndoableOutputs(job),
OriginalsInTrash = job.Files.Any(f => f.OriginalInTrash),
};
diff --git a/src/Filee.Core/Presets/AskedLocation.cs b/src/Filee.Core/Presets/AskedLocation.cs
index f5af3b52..ff8c170b 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 230fa63d..ffab9bc7 100644
--- a/src/Filee.Core/Presets/BuiltInData.cs
+++ b/src/Filee.Core/Presets/BuiltInData.cs
@@ -50,6 +50,7 @@ .. TrimPresets(),
Kind = PresetKind.Tool, Output = { FileNamePattern = "{name}_cropped" },
},
.. ImageEditPresets(),
+ .. HidePresets(),
new()
{
Id = "tool-sheet-studio", NameKey = "preset.sheet_studio", TargetFormat = "pdf",
@@ -116,6 +117,7 @@ .. MotionPhotoPresets(),
.. LivePhotoPresets(),
.. ClipPresets(),
.. MediaToolPresets(),
+ .. MergeImagesPresets(),
new() { Id = "to-docx", Name = "DOCX", TargetFormat = "docx" },
new() { Id = "to-hwpx", Name = "HWPX", TargetFormat = "hwpx" },
.. HwpPresets(),
@@ -207,6 +209,19 @@ public static List ImageEditPresets() =>
},
];
+ ///
+ /// "Pixelate / blur" (added in settings schema 52): parts of a picture (names, faces, numbers) covered before it is
+ /// shared. On the front of the images donut after "Edit pictures".
+ ///
+ public static List HidePresets() =>
+ [
+ new()
+ {
+ Id = "tool-hide", NameKey = "preset.hide", TargetFormat = SameAsSource,
+ Kind = PresetKind.Tool, Tool = Preset.HideTool, Output = { FileNamePattern = "{name}_hidden" },
+ },
+ ];
+
///
/// "Trim transparent borders" (issue #332, added in settings schema 33): a copy cropped to the visible pixels, in the
/// same format. On the images donut's back face after "Remove background".
@@ -276,6 +291,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.
@@ -542,10 +571,11 @@ 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",
- "tool-image-edit"],
- // 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"],
+ "tool-image-edit", "tool-hide"],
+ // 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/HideTool.cs b/src/Filee.Core/Presets/HideTool.cs
new file mode 100644
index 00000000..2a275090
--- /dev/null
+++ b/src/Filee.Core/Presets/HideTool.cs
@@ -0,0 +1,122 @@
+// "Pixelate / blur" runs as an ordinary conversion once its window has the parts of a picture to hide: the tool preset
+// becomes a conversion into the picture's own format with those parts (ImageOptions.HiddenAreas), so the job queue, the
+// toast, cancelling and the output rules work as for any other file. How they are covered (pixelate or blur, and how
+// strongly) is kept in the tool, so the window opens with the last choice. The arithmetic shared by the window's
+// preview and the image engine is here too: where a part lies in pixels, how large the squares or the blur are, and
+// which square a pixel falls in.
+
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
+
+namespace Filee.Core.Presets;
+
+/// Turns the "Pixelate / blur" tool into the conversion that writes a picture with parts of it covered.
+public static class HideTool
+{
+ /// Where a picture goes when its own format can't be written (a camera RAW, a DICOM …).
+ public const string FallbackTarget = "png";
+
+ /// The range of .
+ public const int MinStrength = 1, MaxStrength = 10, DefaultStrength = 5;
+
+ /// The smallest square (or blur) in pixels, whatever the picture's size.
+ public const double MinEffectPixels = 8;
+
+ ///
+ /// Parts smaller than this (in parts of the picture's side) are a click, not a drag: none is added for them.
+ ///
+ public const double MinAreaSide = 0.004;
+
+ /// True when a file is a picture the tool can open.
+ public static bool CanHide(string sourceFormat) =>
+ FormatRegistry.FindById(sourceFormat)?.Category == FormatCategory.Image;
+
+ ///
+ /// The format a picture is written in: its own when it can be written from itself,
+ /// else .
+ ///
+ public static string TargetFor(string sourceFormat, RoutePlanner planner) =>
+ FormatRegistry.FindById(sourceFormat) is { Writable: true } && planner.Plan(sourceFormat, sourceFormat) is not null
+ ? BuiltInData.SameAsSource
+ : FallbackTarget;
+
+ ///
+ /// The conversion that writes a picture as with covered as
+ /// the tool says, with the tool's name and output rule. The tool itself is left as it is.
+ ///
+ public static Preset ConversionOf(Preset tool, string targetFormat, IEnumerable areas)
+ {
+ var preset = tool.Clone();
+ preset.Kind = PresetKind.Convert;
+ preset.Tool = null;
+ preset.SubPresetIds = [];
+ preset.TargetFormat = targetFormat;
+ preset.Image.HiddenAreas = [.. areas.Select(Clamp).OfType()];
+ preset.Image.HideStrength = ClampStrength(preset.Image.HideStrength);
+ // "Only if smaller" would keep the original, every part of it in view, when the covered copy came out larger.
+ preset.Output.OnlyIfSmaller = false;
+ return preset;
+ }
+
+ /// Keeps the window's choice in for the next time.
+ public static void Remember(Preset tool, HideStyle style, int strength)
+ {
+ tool.Image.HideStyle = style;
+ tool.Image.HideStrength = ClampStrength(strength);
+ }
+
+ /// within … .
+ public static int ClampStrength(int strength) => Math.Clamp(strength, MinStrength, MaxStrength);
+
+ ///
+ /// The side of a square (pixelate) or the blur's sigma, in pixels of a ×
+ /// picture: 2 % of its shorter side at the lowest strength to 6.5 % at the highest, never under
+ /// .
+ ///
+ ///
+ /// Tied to the picture rather than to the part being hidden: a screenshot's line of text is about 1.2 % of a desktop
+ /// screen's shorter side and 4 % of a phone's, so at the default strength (4 %) a square is as tall as a line of
+ /// text or taller. Squares much smaller than the letters leave them guessable: text in a known font can be drawn,
+ /// pixelated the same way and compared square by square until it matches.
+ ///
+ public static double EffectSize(int strength, int width, int height) =>
+ Math.Max(MinEffectPixels, Math.Min(Math.Max(1, width), Math.Max(1, height)) * (0.015 + 0.005 * ClampStrength(strength)));
+
+ ///
+ /// within the picture, or null when nothing of it is left (or it is not a number).
+ ///
+ public static HiddenArea? Clamp(HiddenArea area)
+ {
+ if (!double.IsFinite(area.X) || !double.IsFinite(area.Y) || !double.IsFinite(area.Width) || !double.IsFinite(area.Height))
+ return null;
+ var left = Math.Clamp(Math.Min(area.X, area.X + area.Width), 0, 1);
+ var right = Math.Clamp(Math.Max(area.X, area.X + area.Width), 0, 1);
+ var top = Math.Clamp(Math.Min(area.Y, area.Y + area.Height), 0, 1);
+ var bottom = Math.Clamp(Math.Max(area.Y, area.Y + area.Height), 0, 1);
+ return right > left && bottom > top ? new HiddenArea { X = left, Y = top, Width = right - left, Height = bottom - top } : null;
+ }
+
+ ///
+ /// in whole pixels of a × picture, rounded
+ /// outwards (a pixel the part touches is covered), at least one pixel; null when nothing of it is on the picture.
+ ///
+ public static (int X, int Y, int Width, int Height)? PixelBounds(HiddenArea area, int width, int height)
+ {
+ if (width <= 0 || height <= 0 || Clamp(area) is not { } inside)
+ return null;
+ var left = Math.Clamp((int)Math.Floor(inside.X * width + 1e-9), 0, width - 1);
+ var top = Math.Clamp((int)Math.Floor(inside.Y * height + 1e-9), 0, height - 1);
+ var right = Math.Clamp((int)Math.Ceiling((inside.X + inside.Width) * width - 1e-9), left + 1, width);
+ var bottom = Math.Clamp((int)Math.Ceiling((inside.Y + inside.Height) * height - 1e-9), top + 1, height);
+ return (left, top, right - left, bottom - top);
+ }
+
+ ///
+ /// The square a pixel column (or row) falls in, on a grid of -pixel squares from the picture's
+ /// top-left corner: by the pixel's centre, so a cell of 2.5 pixels takes pixels 0–1, 2–4, 5–6 …
+ ///
+ public static int CellOf(int pixel, double cell) => (int)Math.Floor((pixel + 0.5) / cell);
+
+ /// The first pixel column (or row) of square (see ).
+ public static int CellStart(int index, double cell) => (int)Math.Ceiling(index * cell - 0.5);
+}
diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs
index 78446690..b9fa4a06 100644
--- a/src/Filee.Core/Presets/Preset.cs
+++ b/src/Filee.Core/Presets/Preset.cs
@@ -197,6 +197,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;
@@ -213,11 +222,15 @@ public bool RecognizesTextOf(string sourceFormat) =>
///
public const string ImageEditTool = "image-edit";
+ ///
+ /// of "Pixelate / blur": parts of a picture covered before it is shared (names, faces, numbers).
+ ///
+ public const string HideTool = "hide";
+
///
/// : which window it opens, (or null, as saved before there were
- /// other tools), or . A tool this version doesn't know (from a newer
- /// Filee) opens nothing.
- /// Ignored for other kinds.
+ /// other tools), , or . A tool this version
+ /// doesn't know (from a newer Filee) opens nothing. Ignored for other kinds.
///
public string? Tool { get; set; }
@@ -233,6 +246,10 @@ public bool RecognizesTextOf(string sourceFormat) =>
[JsonIgnore]
public bool IsImageEditTool => IsTool && Tool == ImageEditTool;
+ /// True for "Pixelate / blur".
+ [JsonIgnore]
+ public bool IsHideTool => IsTool && Tool == HideTool;
+
///
/// True if converting a file cuts a clip of it (,
/// set by the clip window on the conversion it starts): the file is a video. Other files convert as usual.
@@ -325,6 +342,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
{
@@ -342,6 +377,29 @@ public enum FitsTiffMode
Stretched,
}
+/// How "Pixelate / blur" covers the parts of a picture it hides ().
+public enum HideStyle
+{
+ /// Squares of the average colour of what is under them.
+ Pixelate,
+ /// A strong blur.
+ Blur,
+}
+
+///
+/// A part of a picture to hide, in parts of the picture as it is seen (EXIF orientation applied): 0 to 1 from its left
+/// and top edges.
+///
+public sealed class HiddenArea
+{
+ public double X { get; set; }
+ public double Y { get; set; }
+ public double Width { get; set; }
+ public double Height { get; set; }
+
+ public HiddenArea Clone() => (HiddenArea)MemberwiseClone();
+}
+
/// Raster image encoding options.
public sealed class ImageOptions
{
@@ -375,6 +433,13 @@ public sealed class ImageOptions
[JsonIgnore]
public bool TurnsOrMirrors => Rotation != ImageRotation.None || Flip != ImageFlip.None;
+ ///
+ /// Turns, mirrors or covers parts of the image (): done once, in the first step of a route whose
+ /// engine applies image options (); a file is never passed on as it is.
+ ///
+ [JsonIgnore]
+ public bool ReworksPixels => TurnsOrMirrors || HidesAreas;
+
/// Whether the preset edits picture content, size, or playback speed.
[JsonIgnore]
public bool EditsPicture => Resize != ResizeMode.None || Grayscale || TrimTransparent || RemoveBackground || MaxCompression || ChangesSpeed || CustomSize is not null || MinShortEdge is not null;
@@ -476,6 +541,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.
@@ -492,6 +591,30 @@ public sealed class ImageOptions
/// Icon sizes embedded in an ICO file.
public List IcoSizes { get; set; } = [256, 128, 64, 48, 32, 16];
+ ///
+ /// "Pixelate / blur": how are covered. Kept in the tool, so its window opens with the last
+ /// one. A field of its own (not a value added to another option), read leniently: an older Filee skips it.
+ ///
+ [JsonConverter(typeof(LenientEnumJsonConverter))]
+ public HideStyle HideStyle { get; set; }
+
+ ///
+ /// How large the squares or how wide the blur over is,
+ /// to (see ). Kept in the tool, as
+ /// .
+ ///
+ public int HideStrength { get; set; } = HideTool.DefaultStrength;
+
+ ///
+ /// The parts of the picture "Pixelate / blur" covers. Set by its window on the conversion it starts, never on a
+ /// saved preset.
+ ///
+ public List? HiddenAreas { get; set; }
+
+ /// The picture gets parts of it covered ().
+ [JsonIgnore]
+ public bool HidesAreas => HiddenAreas is { Count: > 0 };
+
/// The playback speeds the preset editor offers ().
public static readonly double[] AnimationSpeeds = [0.25, 0.5, 0.75, 1, 1.5, 2, 4];
@@ -516,6 +639,7 @@ public ImageOptions Clone()
{
var copy = (ImageOptions)MemberwiseClone();
copy.IcoSizes = [.. IcoSizes];
+ copy.HiddenAreas = HiddenAreas?.Select(area => area.Clone()).ToList();
copy.CustomSize = CustomSize is { } size
? new CustomImageSize { Width = size.Width, Height = size.Height, LockAspect = size.LockAspect }
: null;
diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs
index b37c84c4..7e1f954f 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 = 51;
+ 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 aaf4be19..a522385d 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -913,9 +913,56 @@ 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;
@@ -1073,6 +1120,54 @@ private static bool AddImageEditTool(List presets, List
return changed;
}
+ private static bool AddHideTool(List presets, List profiles)
+ {
+ var changed = false;
+ var hide = BuiltInData.HidePresets().Single();
+ if (presets.FirstOrDefault(p => p.Id == hide.Id) is { } existing)
+ {
+ if (!existing.IsHideTool)
+ {
+ existing.Kind = PresetKind.Tool;
+ existing.Tool = Preset.HideTool;
+ existing.SubPresetIds = [];
+ if (existing.TargetFormat == Preset.GroupTarget)
+ existing.TargetFormat = hide.TargetFormat;
+ changed = true;
+ }
+ }
+ else
+ {
+ var at = presets.FindIndex(p => p.Id == "tool-image-edit") is >= 0 and var edit
+ ? edit
+ : presets.FindIndex(p => p.Id == "tool-crop");
+ presets.Insert(at < 0 ? presets.Count : at + 1, hide);
+ changed = true;
+ }
+ if (profiles.FirstOrDefault(p => p.Id == "images") is not { } images
+ || images.PresetIds.Contains(hide.Id) || images.BackPresetIds?.Contains(hide.Id) == true)
+ return changed;
+ var front = images.PresetIds;
+ // Next to the other everyday edits: "Half size", and "Edit pictures" when it follows it. A front without "Half
+ // size" was laid out by the user and stays so, with the back face.
+ var after = front.IndexOf("half-size");
+ if (after < 0)
+ return changed;
+ if (after + 1 < front.Count && front[after + 1] == "tool-image-edit")
+ after++;
+ if (front.Count < ToolbarProfile.MaxSlices)
+ {
+ front.Insert(after + 1, hide.Id);
+ return true;
+ }
+ if (images.BackPresetIds is { } back && back.Count < ToolbarProfile.MaxSlices)
+ {
+ InsertBefore(back, hide.Id, "zip-all");
+ return true;
+ }
+ return changed;
+ }
+
private static bool AddGeo(List presets, List profiles)
{
var changed = false;
diff --git a/src/Filee.Engines/Magick/AreaHider.cs b/src/Filee.Engines/Magick/AreaHider.cs
new file mode 100644
index 00000000..fb6aab06
--- /dev/null
+++ b/src/Filee.Engines/Magick/AreaHider.cs
@@ -0,0 +1,195 @@
+// "Pixelate / blur": covers parts of a picture (ImageOptions.HiddenAreas) so what was there can't be read back. Each
+// part is changed on its own; the rest of the picture keeps its pixels. Also used on the window's preview, so what the
+// user sees while drawing the parts is what the file gets.
+
+using Filee.Core.Presets;
+using ImageMagick;
+
+namespace Filee.Engines.Magick;
+
+/// Pixelates or blurs parts of a picture for good.
+public static class AreaHider
+{
+ ///
+ /// Covers of (upright, as the parts were drawn) as
+ /// say. Nothing happens without parts.
+ ///
+ public static void Apply(IMagickImage image, ImageOptions options)
+ {
+ if (!options.HidesAreas)
+ return;
+ int width = (int)image.Width, height = (int)image.Height;
+ var size = HideTool.EffectSize(options.HideStrength, width, height);
+ foreach (var area in options.HiddenAreas!)
+ {
+ if (HideTool.PixelBounds(area, width, height) is not { } bounds)
+ continue;
+ if (options.HideStyle == HideStyle.Blur)
+ Blur(image, bounds, size);
+ else
+ Pixelate(image, bounds, Math.Round(size));
+ }
+ DropPreviews(image);
+ }
+
+ ///
+ /// The whole of covered with an effect pixels large (a square's side
+ /// or the blur's sigma): the window's preview, of which it shows the parts inside the frames.
+ ///
+ public static void Cover(IMagickImage image, HideStyle style, double size)
+ {
+ var bounds = (0, 0, (int)image.Width, (int)image.Height);
+ if (style == HideStyle.Blur)
+ Blur(image, bounds, size);
+ else
+ Pixelate(image, bounds, size);
+ }
+
+ ///
+ /// Every pixel of becomes the average colour of its square on a grid of
+ /// -pixel squares laid from the picture's top-left corner ().
+ ///
+ ///
+ /// Averaging is many-to-one: a square's worth of pixels becomes one colour, and what made it up is gone. Squares on
+ /// the edge of a part are averaged over all of their pixels, those beside the part too (which stay in view anyway):
+ /// the grid stays the same wherever a part is moved, and two parts that meet look like one. How large a square has
+ /// to be to make text unreadable is 's concern.
+ ///
+ public static void Pixelate(IMagickImage image, (int X, int Y, int Width, int Height) bounds, double cell)
+ {
+ cell = Math.Max(1, cell);
+ int width = (int)image.Width, height = (int)image.Height;
+ var (x, y, w, h) = bounds;
+ if (w <= 0 || h <= 0)
+ return;
+ // The squares the part touches, whole: their pixels beside the part count towards their colour.
+ int firstColumn = HideTool.CellOf(x, cell), lastColumn = HideTool.CellOf(x + w - 1, cell);
+ int firstRow = HideTool.CellOf(y, cell), lastRow = HideTool.CellOf(y + h - 1, cell);
+ var left = Math.Max(0, HideTool.CellStart(firstColumn, cell));
+ var right = Math.Min(width, HideTool.CellStart(lastColumn + 1, cell));
+ var top = Math.Max(0, HideTool.CellStart(firstRow, cell));
+ var bottom = Math.Min(height, HideTool.CellStart(lastRow + 1, cell));
+ int regionWidth = right - left, regionHeight = bottom - top;
+ // RGB(A) whatever the picture holds: the squares are written back as a picture of their own (see below).
+ var mapping = image.HasAlpha ? PixelMapping.RGBA : PixelMapping.RGB;
+ var channels = image.HasAlpha ? 4 : 3;
+ int columns = lastColumn - firstColumn + 1, rows = lastRow - firstRow + 1;
+
+ byte[] data;
+ using (var pixels = image.GetPixels())
+ data = pixels.ToByteArray(left, top, (uint)regionWidth, (uint)regionHeight, mapping)
+ ?? throw new InvalidOperationException("The picture's pixels could not be read.");
+
+ var columnOf = new int[regionWidth];
+ for (var i = 0; i < regionWidth; i++)
+ columnOf[i] = Math.Clamp(HideTool.CellOf(left + i, cell) - firstColumn, 0, columns - 1);
+ var rowOf = new int[regionHeight];
+ for (var j = 0; j < regionHeight; j++)
+ rowOf[j] = Math.Clamp(HideTool.CellOf(top + j, cell) - firstRow, 0, rows - 1);
+
+ var sums = new long[columns * rows * channels];
+ var counts = new int[columns * rows];
+ for (var j = 0; j < regionHeight; j++)
+ {
+ var rowBase = rowOf[j] * columns;
+ var offset = j * regionWidth * channels;
+ for (var i = 0; i < regionWidth; i++, offset += channels)
+ {
+ var square = rowBase + columnOf[i];
+ counts[square]++;
+ var sum = square * channels;
+ for (var c = 0; c < channels; c++)
+ sums[sum + c] += data[offset + c];
+ }
+ }
+ var averages = new byte[sums.Length];
+ for (var square = 0; square < counts.Length; square++)
+ {
+ var count = counts[square];
+ if (count == 0)
+ continue;
+ for (var c = 0; c < channels; c++)
+ averages[square * channels + c] = (byte)((sums[square * channels + c] + count / 2) / count);
+ }
+
+ // Only the part takes the squares' colours.
+ for (var j = y - top; j < y - top + h; j++)
+ {
+ var rowBase = rowOf[j] * columns;
+ for (var i = x - left; i < x - left + w; i++)
+ {
+ var square = (rowBase + columnOf[i]) * channels;
+ Array.Copy(averages, square, data, (j * regionWidth + i) * channels, channels);
+ }
+ }
+ // Copied over the picture as a picture of their own: writing the area into the pixels of a picture as the image
+ // engine reads it left the rest of that picture black.
+ using var squares = new MagickImage();
+ squares.ReadPixels(data, new PixelReadSettings((uint)regionWidth, (uint)regionHeight, StorageType.Char, mapping));
+ image.Composite(squares, left, top, CompositeOperator.Copy);
+ }
+
+ ///
+ /// blurred with a Gaussian of pixels, after its pixels are
+ /// averaged into squares of about a third of it.
+ ///
+ ///
+ /// A light blur can be undone: it is a known convolution, and deconvolving it (sharpening with the inverse) brings
+ /// back text whose strokes it only smeared. Here the part is first scaled down by averaging, which throws pixels
+ /// away for good, and then blurred so widely (a sigma about a line of text, see )
+ /// that the detail of a letter is far below what an 8-bit pixel can hold. Pixels beside the part are blurred with
+ /// it, so its edge doesn't show a seam, but only the part is written back.
+ ///
+ public static void Blur(IMagickImage image, (int X, int Y, int Width, int Height) bounds, double sigma)
+ {
+ sigma = Math.Max(1, sigma);
+ int width = (int)image.Width, height = (int)image.Height;
+ var (x, y, w, h) = bounds;
+ if (w <= 0 || h <= 0)
+ return;
+ var margin = (int)Math.Ceiling(2 * sigma);
+ var left = Math.Max(0, x - margin);
+ var top = Math.Max(0, y - margin);
+ var right = Math.Min(width, x + w + margin);
+ var bottom = Math.Min(height, y + h + margin);
+ int regionWidth = right - left, regionHeight = bottom - top;
+
+ using var part = image.CloneArea(new MagickGeometry(left, top, (uint)regionWidth, (uint)regionHeight));
+ part.ResetPage();
+ var step = Math.Max(1, (int)Math.Floor(sigma / 3));
+ if (step > 1)
+ {
+ part.Scale(new MagickGeometry((uint)Math.Max(1, (regionWidth + step - 1) / step), (uint)Math.Max(1, (regionHeight + step - 1) / step))
+ {
+ IgnoreAspectRatio = true,
+ });
+ }
+ part.GaussianBlur(0, sigma / step);
+ if (step > 1)
+ {
+ part.FilterType = FilterType.Triangle;
+ part.Resize(new MagickGeometry((uint)regionWidth, (uint)regionHeight) { IgnoreAspectRatio = true });
+ }
+ part.Crop(new MagickGeometry(x - left, y - top, (uint)w, (uint)h));
+ part.ResetPage();
+ image.Composite(part, x, y, CompositeOperator.Copy);
+ }
+
+ ///
+ /// Drops the small previews a file may carry of the picture as it was: EXIF's thumbnail, Photoshop's (in its 8BIM
+ /// resources) and XMP's. Viewers that show them would show the hidden parts.
+ ///
+ internal static void DropPreviews(IMagickImage image)
+ {
+ if (image.GetExifProfile() is { } exif && exif.ThumbnailLength > 0)
+ {
+ exif.RemoveThumbnail();
+ image.SetProfile(exif);
+ }
+ if (image.HasProfile("8bim"))
+ image.RemoveProfile("8bim");
+ if (image.GetXmpProfile() is { } xmp
+ && System.Text.Encoding.UTF8.GetString(xmp.ToByteArray()).Contains("Thumbnails", StringComparison.Ordinal))
+ image.RemoveProfile("xmp");
+ }
+}
diff --git a/src/Filee.Engines/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs
index 9864c138..a043e529 100644
--- a/src/Filee.Engines/Magick/ImageEncoder.cs
+++ b/src/Filee.Engines/Magick/ImageEncoder.cs
@@ -293,6 +293,10 @@ public static void ApplyOptions(IMagickImage image, ImageOptions options,
var turned = options.TurnsOrMirrors || image.Orientation is not (OrientationType.TopLeft or OrientationType.Undefined);
image.AutoOrient();
+ // "Pixelate / blur": the parts were drawn on the picture as it is seen, before it is turned or resized. It also
+ // drops the small previews of the uncovered picture a file can carry.
+ AreaHider.Apply(image, options);
+
// After AutoOrient, so a phone photo turns the way it looks, not the way its pixels are stored.
Turn(image, options);
// The small preview kept in the EXIF data still shows the picture as it was: Explorer and photo apps that
diff --git a/src/Filee.Engines/Magick/ImageJoiner.cs b/src/Filee.Engines/Magick/ImageJoiner.cs
new file mode 100644
index 00000000..d9c8bb91
--- /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 2700afdb..dc079a78 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/HideWindowTests.cs b/tests/Filee.App.Tests/HideWindowTests.cs
new file mode 100644
index 00000000..5cc3dcce
--- /dev/null
+++ b/tests/Filee.App.Tests/HideWindowTests.cs
@@ -0,0 +1,493 @@
+// The "Pixelate / blur" window: the empty picture's hint, frames showing the picture already pixelated or blurred, the
+// strip of several files and the end summary (screenshots in light, dark, Material You and a theme.json, short, long
+// and right-to-left languages), drawing and dragging frames with the mouse, its keys, the conversion it starts and the
+// choice it remembers.
+
+using System.IO;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Headless.XUnit;
+using Avalonia.Input;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using Filee.App.Controls;
+using Filee.App.Services;
+using Filee.App.ViewModels;
+using Filee.App.Views;
+using Filee.Core.Localization;
+using Filee.Core.Presets;
+using Filee.Core.Settings;
+using ImageMagick;
+using ImageMagick.Drawing;
+using static Filee.App.Tests.RenderTests;
+
+namespace Filee.App.Tests;
+
+public class HideWindowTests
+{
+ public static TheoryData Looks => new()
+ {
+ { "en", "light" }, { "en", "dark" }, { "ko", "light" }, { "ko", "dark" }, { "de", "light" }, { "de", "dark" },
+ { "ar", "light" }, { "ar", "dark" }, { "ru", "light" }, { "hi", "light" }, { "ne", "dark" }, { "ja", "light" },
+ { "en", "material" }, { "en", "custom" },
+ };
+
+ /// The frames of the screenshots: a name, a phone number, a face.
+ private static readonly Rect[] Frames = [new(0.062, 0.148, 0.16, 0.07), new(0.07, 0.62, 0.38, 0.08), new(0.63, 0.19, 0.24, 0.39)];
+
+ [AvaloniaTheory]
+ [MemberData(nameof(Looks))]
+ public void A_picture_shows_the_hint_then_its_frames_pixelated_or_blurred(string language, string look)
+ {
+ WithWindow(language, look, ["chat.png"], (window, vm, suffix, _) =>
+ {
+ Assert.False(vm.HasMultipleFiles);
+ Assert.Equal(AppHost.Get()["hide.save"], vm.SaveText);
+ Assert.True(vm.ShowEmptyHint);
+ Assert.False(vm.CanSave);
+ Assert.False(vm.SaveCommand.CanExecute(null));
+ Settle(window);
+ Save(window, $"hide-empty-{suffix}.png");
+
+ vm.Strength = 5;
+ vm.SetStyle(HideStyle.Pixelate);
+ foreach (var frame in Frames)
+ vm.Areas.Add(frame);
+ vm.Areas.Select(1);
+ WaitForCover(vm);
+ Settle(window);
+ Assert.False(vm.ShowEmptyHint);
+ Assert.True(vm.CanSave);
+ Save(window, $"hide-pixelate-{suffix}.png");
+
+ vm.SetStyle(HideStyle.Blur);
+ vm.Strength = 7;
+ vm.Areas.Select(2);
+ WaitForCover(vm);
+ Settle(window);
+ Save(window, $"hide-blur-{suffix}.png");
+ });
+ }
+
+ [AvaloniaTheory]
+ [InlineData("en", "light")]
+ [InlineData("en", "dark")]
+ [InlineData("de", "light")]
+ [InlineData("ar", "dark")]
+ [InlineData("ko", "light")]
+ [InlineData("ru", "dark")]
+ public void Several_files_show_a_strip_and_the_frames_carry_on_to_the_next(string language, string look)
+ {
+ WithWindow(language, look, ["one.png", "two.png", "spinner.gif", "four.png"], (window, vm, suffix, exported) =>
+ {
+ Assert.True(vm.HasMultipleFiles);
+ Assert.Equal(AppHost.Get()["hide.save_next"], vm.SaveText);
+
+ vm.Areas.Add(Frames[0]);
+ vm.Areas.Add(Frames[2]);
+ _ = vm.SaveAsync();
+ WaitFor(() => vm.CurrentIndex == 1 && !vm.IsBusy && vm.CoverImage is not null);
+
+ // The second picture starts with the first one's frames, none selected.
+ Assert.Equal(2, vm.Areas.Count);
+ AssertClose(Frames[0], vm.Areas.Areas[0]);
+ AssertClose(Frames[2], vm.Areas.Areas[1]);
+ Assert.Equal(-1, vm.Areas.Selected);
+ Assert.False(vm.CanUndo);
+ vm.Areas.Select(0);
+ WaitForCover(vm);
+ Settle(window);
+ Save(window, $"hide-multi-{suffix}.png");
+
+ // Skipped; the animated GIF can't be opened and fails on its own; the last one is saved.
+ _ = vm.SkipAsync();
+ WaitFor(() => vm.CurrentIndex == 3 && !vm.IsBusy && vm.PreviewImage is not null);
+ Assert.Equal(
+ [CropItemState.Done, CropItemState.Skipped, CropItemState.Failed, CropItemState.Current],
+ vm.Queue.Select(q => q.State));
+ _ = vm.SaveAsync();
+ WaitFor(() => vm.IsFinished);
+ Assert.Equal((2, 1, 1), (vm.DoneCount, vm.SkippedCount, vm.FailedCount));
+ Assert.Equal(2, exported.Count);
+ Settle(window);
+ Save(window, $"hide-summary-{suffix}.png");
+ });
+ }
+
+ [AvaloniaFact]
+ public void Dragging_on_the_picture_draws_a_frame_that_moves_sizes_and_shows_the_cover_at_once()
+ {
+ WithWindow("en", "light", ["chat.png"], (window, vm, _, _) =>
+ {
+ var canvas = window.HideCanvas;
+ var picture = canvas.CalculateImageBounds();
+ var origin = (Vector)canvas.TranslatePoint(default, window)!.Value;
+ Point At(double x, double y) => new Point(picture.X + picture.Width * x, picture.Y + picture.Height * y) + origin;
+
+ // A click beside every frame draws nothing.
+ window.MouseDown(At(0.5, 0.5), MouseButton.Left);
+ window.MouseUp(At(0.5, 0.5), MouseButton.Left);
+ Assert.Equal(0, vm.Areas.Count);
+
+ // A drag draws one, selected, already covered while the button is still down.
+ window.MouseDown(At(0.1, 0.1), MouseButton.Left);
+ window.MouseMove(At(0.2, 0.15));
+ window.MouseMove(At(0.4, 0.3));
+ Pump();
+ Assert.Equal(1, vm.Areas.Count);
+ Assert.Equal(0, vm.Areas.Selected);
+ WaitForCover(vm);
+ Settle(window);
+ Save(window, "hide-drag-en.png");
+ window.MouseUp(At(0.4, 0.3), MouseButton.Left);
+ AssertClose(new Rect(0.1, 0.1, 0.3, 0.2), vm.Areas.Areas[0]);
+
+ // Moved by its middle, sized by its corner; each drag is one undo step.
+ window.MouseDown(At(0.25, 0.2), MouseButton.Left);
+ window.MouseMove(At(0.3, 0.25));
+ window.MouseMove(At(0.35, 0.3));
+ window.MouseUp(At(0.35, 0.3), MouseButton.Left);
+ AssertClose(new Rect(0.2, 0.2, 0.3, 0.2), vm.Areas.Areas[0]);
+ var corner = At(0.5, 0.4);
+ window.MouseDown(corner, MouseButton.Left);
+ window.MouseMove(corner + new Vector(10, 10));
+ window.MouseMove(At(0.6, 0.5));
+ window.MouseUp(At(0.6, 0.5), MouseButton.Left);
+ AssertClose(new Rect(0.2, 0.2, 0.4, 0.3), vm.Areas.Areas[0]);
+ vm.Undo();
+ AssertClose(new Rect(0.2, 0.2, 0.3, 0.2), vm.Areas.Areas[0]);
+ vm.Undo();
+ AssertClose(new Rect(0.1, 0.1, 0.3, 0.2), vm.Areas.Areas[0]);
+ vm.Redo();
+ AssertClose(new Rect(0.2, 0.2, 0.3, 0.2), vm.Areas.Areas[0]);
+
+ // A second frame; a click on the first selects it again, a click beside both selects none.
+ window.MouseDown(At(0.7, 0.7), MouseButton.Left);
+ window.MouseMove(At(0.8, 0.8));
+ window.MouseMove(At(0.9, 0.9));
+ window.MouseUp(At(0.9, 0.9), MouseButton.Left);
+ Assert.Equal((2, 1), (vm.Areas.Count, vm.Areas.Selected));
+ window.MouseDown(At(0.3, 0.3), MouseButton.Left);
+ window.MouseUp(At(0.3, 0.3), MouseButton.Left);
+ Assert.Equal(0, vm.Areas.Selected);
+ window.MouseDown(At(0.05, 0.9), MouseButton.Left);
+ window.MouseUp(At(0.05, 0.9), MouseButton.Left);
+ Assert.Equal(-1, vm.Areas.Selected);
+ Assert.False(vm.HasSelection);
+
+ // Under the pointer a frame that isn't selected shows its full outline.
+ vm.Areas.Select(1);
+ window.MouseMove(At(0.3, 0.3));
+ Settle(window);
+ Save(window, "hide-hover-en.png");
+ });
+ }
+
+ [AvaloniaFact]
+ public void Keys_pick_move_remove_and_undo_frames_change_the_cover_save_and_close()
+ {
+ WithWindow("en", "light", ["chat.png"], (window, vm, _, exported) =>
+ {
+ // Without frames Enter saves nothing, and Tab leaves the frames alone.
+ Press(window, Key.Enter);
+ Assert.Empty(exported);
+
+ vm.Areas.Add(new Rect(0.25, 0.25, 0.5, 0.5)); // 400, 250, 800 × 500 of 1600 × 1000
+ vm.Areas.Add(new Rect(0.1, 0.1, 0.1, 0.1));
+ vm.Areas.Select(-1);
+
+ // Tab goes from frame to frame, Shift+Tab back.
+ Press(window, Key.Tab);
+ Assert.Equal(0, vm.Areas.Selected);
+ Press(window, Key.Tab);
+ Assert.Equal(1, vm.Areas.Selected);
+ Press(window, Key.Tab);
+ Assert.Equal(0, vm.Areas.Selected);
+ Press(window, Key.Tab, RawInputModifiers.Shift);
+ Assert.Equal(1, vm.Areas.Selected);
+ Press(window, Key.Tab, RawInputModifiers.Shift);
+ Assert.Equal(0, vm.Areas.Selected);
+
+ // Arrows move it by pixels of the picture, Shift by ten.
+ Press(window, Key.Right);
+ Press(window, Key.Right);
+ Press(window, Key.Down, RawInputModifiers.Shift);
+ Assert.Equal(new Rect(402 / 1600.0, 260 / 1000.0, 0.5, 0.5), vm.Areas.Areas[0]);
+
+ // Delete removes it, Ctrl+Z brings it back, Ctrl+Y removes it again.
+ Press(window, Key.Delete);
+ Assert.Equal(1, vm.Areas.Count);
+ Press(window, Key.Z, RawInputModifiers.Control);
+ Assert.Equal(2, vm.Areas.Count);
+ Press(window, Key.Y, RawInputModifiers.Control);
+ Assert.Equal(1, vm.Areas.Count);
+ Press(window, Key.Z, RawInputModifiers.Control);
+
+ // R clears every frame, undoably.
+ Press(window, Key.R);
+ Assert.Equal(0, vm.Areas.Count);
+ Press(window, Key.Z, RawInputModifiers.Control);
+ Assert.Equal(2, vm.Areas.Count);
+
+ // P, B and + / − choose the cover.
+ Press(window, Key.B);
+ Assert.Equal(HideStyle.Blur, vm.Style);
+ Press(window, Key.P);
+ Assert.Equal(HideStyle.Pixelate, vm.Style);
+ var strength = vm.Strength;
+ Press(window, Key.OemPlus);
+ Assert.Equal(strength + 1, vm.Strength);
+ Press(window, Key.OemMinus);
+ Press(window, Key.OemMinus);
+ Assert.Equal(strength - 1, vm.Strength);
+ for (var i = 0; i < 20; i++)
+ Press(window, Key.OemMinus);
+ Assert.Equal(HideTool.MinStrength, vm.Strength);
+
+ // Enter saves: one conversion of the picture with both frames, as chosen, and the window closes.
+ vm.SetStyle(HideStyle.Blur);
+ vm.Strength = 8;
+ var closed = false;
+ window.Closed += (_, _) => closed = true;
+ Press(window, Key.Enter);
+ WaitFor(() => closed);
+ var (path, conversion) = Assert.Single(exported);
+ Assert.EndsWith("chat.png", path);
+ Assert.Equal(PresetKind.Convert, conversion.Kind);
+ Assert.Equal(BuiltInData.SameAsSource, conversion.TargetFormat);
+ Assert.Equal(HideStyle.Blur, conversion.Image.HideStyle);
+ Assert.Equal(8, conversion.Image.HideStrength);
+ Assert.Equal(2, conversion.Image.HiddenAreas!.Count);
+ Assert.Equal(402 / 1600.0, conversion.Image.HiddenAreas[0].X, 6);
+ });
+ }
+
+ [AvaloniaFact]
+ public void Escape_closes_without_saving_and_the_choice_is_remembered_in_the_tool()
+ {
+ WithWindow("en", "light", ["chat.png"], (window, vm, _, exported) =>
+ {
+ vm.Areas.Add(Frames[0]);
+ vm.SetStyle(HideStyle.Blur);
+ vm.Strength = 9;
+ var closed = false;
+ window.Closed += (_, _) => closed = true;
+ Press(window, Key.Escape);
+ Assert.True(closed);
+ Assert.Empty(exported);
+ var saved = AppHost.Get().FindPreset("tool-hide")!;
+ Assert.Equal((HideStyle.Blur, 9), (saved.Image.HideStyle, saved.Image.HideStrength));
+ Assert.Null(saved.Image.HiddenAreas);
+ });
+
+ // The next window opens with it.
+ WithWindow("en", "light", ["chat.png"], (_, vm, _, _) => Assert.Equal((HideStyle.Blur, 9), (vm.Style, vm.Strength)),
+ resetTool: false);
+ var store = AppHost.Get();
+ HideTool.Remember(store.FindPreset("tool-hide")!, HideStyle.Pixelate, HideTool.DefaultStrength);
+ store.SaveLibrary();
+ }
+
+ [AvaloniaTheory]
+ [InlineData("de", "light")]
+ [InlineData("ru", "dark")]
+ [InlineData("ne", "light")]
+ [InlineData("ar", "light")]
+ [InlineData("en", "light")]
+ public void At_its_smallest_the_toolbar_still_fits_the_window(string language, string look)
+ {
+ WithWindow(language, look, ["one.png", "two.png"], (window, vm, suffix, _) =>
+ {
+ window.Width = window.MinWidth;
+ window.Height = window.MinHeight;
+ vm.Areas.Add(Frames[0]);
+ WaitForCover(vm);
+ Settle(window);
+ // Every button of the toolbar is whole inside the window (also in a right-to-left one), Hide & Next too.
+ var buttons = window.GetVisualDescendants().OfType().Single(b => b.Name == "Toolbar")
+ .GetVisualDescendants().OfType