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,8Z M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7Z M7,17V1H5V5H1V7H5V17A2,2 0 0,0 7,19H17V23H19V19H23V17M17,15H19V7C19,5.89 18.1,5 17,5H9V7H17V15Z + + M3,3H9V9H3Z M15,3H21V9H15Z M9,9H15V15H9Z M3,15H9V21H3Z M15,15H21V21H15Z M9.64,7.64C9.87,7.14 10,6.59 10,6A4,4 0 0,0 6,2A4,4 0 0,0 2,6A4,4 0 0,0 6,10C6.59,10 7.14,9.87 7.64,9.64L10,12L7.64,14.36C7.14,14.13 6.59,14 6,14A4,4 0 0,0 2,18A4,4 0 0,0 6,22A4,4 0 0,0 10,18C10,17.41 9.87,16.86 9.64,16.36L12,14L19,21H22V20L9.64,7.64M6,8A2,2 0 0,1 4,6A2,2 0 0,1 6,4A2,2 0 0,1 8,6A2,2 0 0,1 6,8M6,20A2,2 0 0,1 4,18A2,2 0 0,1 6,16A2,2 0 0,1 8,18A2,2 0 0,1 6,20M19,3L13,9L15,11L22,4V3H19Z M14,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2M18,20H6V4H13V9H18V20Z M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z diff --git a/src/Filee.App/ViewModels/CropOverlayViewModel.cs b/src/Filee.App/ViewModels/CropOverlayViewModel.cs index 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +