diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 15dc840ff..3f6b3e6e5 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -253,6 +253,14 @@ "crop.state_done": "تم الاقتصاص", "crop.state_skipped": "تم التخطي", "crop.state_failed": "فشل", + "crop.perspective": "المنظور", + "crop.perspective_tip": "المنظور: حرّك كل زاوية على حدة لتقويم صورة مائلة", + "crop.perspective_pdf": "يعمل المنظور على الصور فقط، لا على صفحات PDF", + "crop.hint_keys_perspective": "يختار Tab زاوية، وتحرّكها مفاتيح الأسهم، ومع Shift تكون الخطوة أكبر بعشر مرات · Esc للإغلاق", + "crop.corner_top_left": "الزاوية العلوية اليسرى", + "crop.corner_top_right": "الزاوية العلوية اليمنى", + "crop.corner_bottom_right": "الزاوية السفلية اليمنى", + "crop.corner_bottom_left": "الزاوية السفلية اليسرى", "preset.hide": "بكسلة / تمويه", "preset.hide.donut": "بكسلة", "error.cannot_hide": "البكسلة والتمويه متاحان للصور فقط", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index e611705a9..5755d0ee3 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -253,6 +253,14 @@ "crop.state_done": "Zugeschnitten", "crop.state_skipped": "Übersprungen", "crop.state_failed": "Fehlgeschlagen", + "crop.perspective": "Perspektive", + "crop.perspective_tip": "Perspektive: jede Ecke einzeln verschieben, um ein schräges Bild gerade zu ziehen", + "crop.perspective_pdf": "Perspektive funktioniert bei Bildern, nicht bei PDF-Seiten", + "crop.hint_keys_perspective": "Tab wählt eine Ecke, Pfeiltasten verschieben sie, Umschalt × 10 · Esc schließt", + "crop.corner_top_left": "Ecke oben links", + "crop.corner_top_right": "Ecke oben rechts", + "crop.corner_bottom_right": "Ecke unten rechts", + "crop.corner_bottom_left": "Ecke unten links", "preset.hide": "Verpixeln / Weichzeichnen", "preset.hide.donut": "Verpixeln", "error.cannot_hide": "Verpixeln und Weichzeichnen ist nur bei Bildern möglich", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index 2a16fcb79..cadb814c9 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -262,6 +262,14 @@ "crop.state_done": "Cropped", "crop.state_skipped": "Skipped", "crop.state_failed": "Failed", + "crop.perspective": "Perspective", + "crop.perspective_tip": "Perspective: move each corner on its own to straighten a slanted picture", + "crop.perspective_pdf": "Perspective works on pictures, not on PDF pages", + "crop.hint_keys_perspective": "Tab picks a corner, arrow keys move it, Shift × 10 · Esc closes", + "crop.corner_top_left": "Top-left corner", + "crop.corner_top_right": "Top-right corner", + "crop.corner_bottom_right": "Bottom-right corner", + "crop.corner_bottom_left": "Bottom-left corner", "preset.hide": "Pixelate / blur", "preset.hide.donut": "Pixelate", "error.cannot_hide": "Only pictures can be pixelated or blurred", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index b838f7ffe..bed24f2b5 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -253,6 +253,14 @@ "crop.state_done": "Recortado", "crop.state_skipped": "Omitido", "crop.state_failed": "Error", + "crop.perspective": "Perspectiva", + "crop.perspective_tip": "Perspectiva: mueve cada esquina por separado para enderezar una imagen inclinada", + "crop.perspective_pdf": "La perspectiva funciona con imágenes, no con páginas PDF", + "crop.hint_keys_perspective": "Tab elige una esquina, las flechas la mueven, Mayús × 10 · Esc cierra", + "crop.corner_top_left": "Esquina superior izquierda", + "crop.corner_top_right": "Esquina superior derecha", + "crop.corner_bottom_right": "Esquina inferior derecha", + "crop.corner_bottom_left": "Esquina inferior izquierda", "preset.hide": "Pixelar / difuminar", "preset.hide.donut": "Pixelar", "error.cannot_hide": "Solo se pueden pixelar o difuminar imágenes", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 308664e5e..8c6fa877c 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -262,6 +262,14 @@ "crop.state_done": "Recadré", "crop.state_skipped": "Ignoré", "crop.state_failed": "Échec", + "crop.perspective": "Perspective", + "crop.perspective_tip": "Perspective : déplacez chaque coin séparément pour redresser une image de biais", + "crop.perspective_pdf": "La perspective fonctionne sur les images, pas sur les pages PDF", + "crop.hint_keys_perspective": "Tab choisit un coin, les flèches le déplacent, Maj × 10 · Échap ferme", + "crop.corner_top_left": "Coin supérieur gauche", + "crop.corner_top_right": "Coin supérieur droit", + "crop.corner_bottom_right": "Coin inférieur droit", + "crop.corner_bottom_left": "Coin inférieur gauche", "preset.hide": "Pixeliser / flouter", "preset.hide.donut": "Pixeliser", "error.cannot_hide": "Seules les images peuvent être pixelisées ou floutées", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 852136162..b106d37ad 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -253,6 +253,14 @@ "crop.state_done": "क्रॉप हो गया", "crop.state_skipped": "छोड़ा गया", "crop.state_failed": "विफल", + "crop.perspective": "परिप्रेक्ष्य", + "crop.perspective_tip": "परिप्रेक्ष्य: तिरछी तस्वीर सीधी करने के लिए हर कोना अलग से खिसकाएँ", + "crop.perspective_pdf": "परिप्रेक्ष्य तस्वीरों पर काम करता है, PDF पन्नों पर नहीं", + "crop.hint_keys_perspective": "Tab से कोना चुनें, ऐरो कुंजियाँ उसे खिसकाती हैं, Shift से 10 गुना · Esc से बंद करें", + "crop.corner_top_left": "ऊपर बायाँ कोना", + "crop.corner_top_right": "ऊपर दायाँ कोना", + "crop.corner_bottom_right": "नीचे दायाँ कोना", + "crop.corner_bottom_left": "नीचे बायाँ कोना", "preset.hide": "पिक्सलेट / धुंधला", "preset.hide.donut": "पिक्सलेट", "error.cannot_hide": "पिक्सलेट और धुंधला केवल इमेज पर काम करता है", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index e62e8ea73..af5b4690c 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -253,6 +253,14 @@ "crop.state_done": "Ritagliato", "crop.state_skipped": "Saltato", "crop.state_failed": "Non riuscito", +"crop.perspective": "Prospettiva", +"crop.perspective_tip": "Prospettiva: sposta ogni angolo da solo per raddrizzare un'immagine inclinata", +"crop.perspective_pdf": "La prospettiva funziona sulle immagini, non sulle pagine PDF", +"crop.hint_keys_perspective": "Tab sceglie un angolo, i tasti freccia lo spostano, Maiusc × 10 · Esc chiude", +"crop.corner_top_left": "Angolo in alto a sinistra", +"crop.corner_top_right": "Angolo in alto a destra", +"crop.corner_bottom_right": "Angolo in basso a destra", +"crop.corner_bottom_left": "Angolo in basso a sinistra", "preset.hide": "Pixella / sfoca", "preset.hide.donut": "Pixella", "error.cannot_hide": "Solo le immagini possono essere pixellate o sfocate", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index b007d5fef..89ca0d7a8 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -253,6 +253,14 @@ "crop.state_done": "トリミング済み", "crop.state_skipped": "スキップ", "crop.state_failed": "失敗", + "crop.perspective": "遠近補正", + "crop.perspective_tip": "遠近補正:角を 1 つずつ動かして、斜めに写った画像をまっすぐにする", + "crop.perspective_pdf": "遠近補正は画像に使えます。PDF のページには使えません", + "crop.hint_keys_perspective": "Tab で角を選択、矢印キーで移動、Shift で 10 倍 · Esc で閉じる", + "crop.corner_top_left": "左上の角", + "crop.corner_top_right": "右上の角", + "crop.corner_bottom_right": "右下の角", + "crop.corner_bottom_left": "左下の角", "preset.hide": "モザイク・ぼかし", "preset.hide.donut": "モザイク", "error.cannot_hide": "モザイクとぼかしは画像でのみ使えます", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 04f70f332..aa837b221 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -262,6 +262,14 @@ "crop.state_done": "자르기 완료", "crop.state_skipped": "건너뜀", "crop.state_failed": "실패", + "crop.perspective": "원근 보정", + "crop.perspective_tip": "원근 보정: 모서리를 하나씩 옮겨 비스듬한 이미지를 반듯하게 펴기", + "crop.perspective_pdf": "원근 보정은 이미지에만 쓸 수 있고 PDF 페이지에는 쓸 수 없어요", + "crop.hint_keys_perspective": "Tab으로 모서리 고르기, 화살표 키로 이동, Shift는 10배 · Esc로 닫기", + "crop.corner_top_left": "왼쪽 위 모서리", + "crop.corner_top_right": "오른쪽 위 모서리", + "crop.corner_bottom_right": "오른쪽 아래 모서리", + "crop.corner_bottom_left": "왼쪽 아래 모서리", "preset.hide": "모자이크·흐림", "preset.hide.donut": "모자이크", "error.cannot_hide": "모자이크와 흐림은 이미지에만 할 수 있어요", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 45fed94ed..f728b81cc 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -253,6 +253,14 @@ "crop.state_done": "क्रप गरियो", "crop.state_skipped": "छोडियो", "crop.state_failed": "असफल भयो", + "crop.perspective": "परिप्रेक्ष्य", + "crop.perspective_tip": "परिप्रेक्ष्य: छड्के तस्बिर सीधा बनाउन हरेक कुना छुट्टाछुट्टै सार्नुहोस्", + "crop.perspective_pdf": "परिप्रेक्ष्य तस्बिरमा मात्र चल्छ, PDF पानामा चल्दैन", + "crop.hint_keys_perspective": "Tab ले कुना छान्छ, एरो कीहरूले सार्छन्, Shift थिच्दा १० गुणा · Esc ले बन्द गर्छ", + "crop.corner_top_left": "माथिल्लो बायाँ कुना", + "crop.corner_top_right": "माथिल्लो दायाँ कुना", + "crop.corner_bottom_right": "तल्लो दायाँ कुना", + "crop.corner_bottom_left": "तल्लो बायाँ कुना", "preset.hide": "पिक्सलेट / धमिलो", "preset.hide.donut": "पिक्सलेट", "error.cannot_hide": "पिक्सलेट र धमिलो पार्ने कार्य केवल छविहरूमा मात्र काम गर्छ", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index f9d8bc5bc..9ea16c302 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -262,6 +262,14 @@ "crop.state_done": "Wykadrowany", "crop.state_skipped": "Pominięty", "crop.state_failed": "Błąd", + "crop.perspective": "Perspektywa", + "crop.perspective_tip": "Perspektywa: przesuwaj każdy róg osobno, by wyprostować przekrzywione zdjęcie", + "crop.perspective_pdf": "Perspektywa działa na obrazach, nie na stronach PDF", + "crop.hint_keys_perspective": "Tab wybiera róg, strzałki go przesuwają, Shift × 10 · Esc zamyka", + "crop.corner_top_left": "Lewy górny róg", + "crop.corner_top_right": "Prawy górny róg", + "crop.corner_bottom_right": "Prawy dolny róg", + "crop.corner_bottom_left": "Lewy dolny róg", "preset.hide": "Pikselizacja / rozmycie", "preset.hide.donut": "Mozaika", "error.cannot_hide": "Pikselizować i rozmywać można tylko obrazy", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index d7bd76a85..e327748a0 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -263,6 +263,14 @@ "crop.state_done": "Recortado", "crop.state_skipped": "Pulado", "crop.state_failed": "Falhou", + "crop.perspective": "Perspectiva", + "crop.perspective_tip": "Perspectiva: mova cada canto separadamente para endireitar uma imagem inclinada", + "crop.perspective_pdf": "A perspectiva funciona em imagens, não em páginas PDF", + "crop.hint_keys_perspective": "Tab escolhe um canto, as setas o movem, Shift × 10 · Esc fecha", + "crop.corner_top_left": "Canto superior esquerdo", + "crop.corner_top_right": "Canto superior direito", + "crop.corner_bottom_right": "Canto inferior direito", + "crop.corner_bottom_left": "Canto inferior esquerdo", "preset.hide": "Pixelizar / desfocar", "preset.hide.donut": "Pixelizar", "error.cannot_hide": "Só é possível pixelizar ou desfocar imagens", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index b50fd59fd..d50366cf2 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -262,6 +262,14 @@ "crop.state_done": "Recortado", "crop.state_skipped": "Ignorado", "crop.state_failed": "Falhou", + "crop.perspective": "Perspetiva", + "crop.perspective_tip": "Perspetiva: mova cada canto separadamente para endireitar uma imagem inclinada", + "crop.perspective_pdf": "A perspetiva funciona em imagens, não em páginas PDF", + "crop.hint_keys_perspective": "A tecla Tab escolhe um canto e as teclas de direção movem-no; Shift multiplica o movimento por 10 · Esc fecha", + "crop.corner_top_left": "Canto superior esquerdo", + "crop.corner_top_right": "Canto superior direito", + "crop.corner_bottom_right": "Canto inferior direito", + "crop.corner_bottom_left": "Canto inferior esquerdo", "preset.hide": "Pixelizar / desfocar", "preset.hide.donut": "Pixelizar", "error.cannot_hide": "Só é possível pixelizar ou desfocar imagens", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 6f1ce9adb..00dc7454d 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -262,6 +262,14 @@ "crop.state_done": "Обрезан", "crop.state_skipped": "Пропущен", "crop.state_failed": "Ошибка", + "crop.perspective": "Перспектива", + "crop.perspective_tip": "Перспектива: двигайте каждый угол отдельно, чтобы выпрямить снятое под углом изображение", + "crop.perspective_pdf": "Перспектива работает с изображениями, но не со страницами PDF", + "crop.hint_keys_perspective": "Tab выбирает угол, стрелки двигают его, Shift × 10 · Esc закрывает", + "crop.corner_top_left": "Левый верхний угол", + "crop.corner_top_right": "Правый верхний угол", + "crop.corner_bottom_right": "Правый нижний угол", + "crop.corner_bottom_left": "Левый нижний угол", "preset.hide": "Пикселизация / размытие", "preset.hide.donut": "Мозаика", "error.cannot_hide": "Пикселизировать и размывать можно только изображения", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index c047d1766..6bd724f0e 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -253,6 +253,14 @@ "crop.state_done": "ครอบตัดแล้ว", "crop.state_skipped": "ข้ามแล้ว", "crop.state_failed": "ล้มเหลว", + "crop.perspective": "มุมมองเปอร์สเปกทีฟ", + "crop.perspective_tip": "มุมมองเปอร์สเปกทีฟ: เลื่อนแต่ละมุมแยกกันเพื่อดัดภาพที่เอียงให้ตรง", + "crop.perspective_pdf": "มุมมองเปอร์สเปกทีฟใช้ได้กับรูปภาพ ใช้กับหน้า PDF ไม่ได้", + "crop.hint_keys_perspective": "Tab เพื่อเลือกมุม, ปุ่มลูกศรเพื่อเลื่อน, Shift × 10 · Esc เพื่อปิด", + "crop.corner_top_left": "มุมบนซ้าย", + "crop.corner_top_right": "มุมบนขวา", + "crop.corner_bottom_right": "มุมล่างขวา", + "crop.corner_bottom_left": "มุมล่างซ้าย", "preset.hide": "โมเสก / เบลอ", "preset.hide.donut": "โมเสก", "error.cannot_hide": "ใส่โมเสกหรือเบลอได้เฉพาะไฟล์รูปภาพเท่านั้น", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 0b195c35a..92ee11393 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -253,6 +253,14 @@ "crop.state_done": "Kırpıldı", "crop.state_skipped": "Atlandı", "crop.state_failed": "Başarısız", + "crop.perspective": "Perspektif", + "crop.perspective_tip": "Perspektif: eğik bir resmi düzeltmek için her köşeyi ayrı ayrı taşıyın", + "crop.perspective_pdf": "Perspektif resimlerde çalışır, PDF sayfalarında çalışmaz", + "crop.hint_keys_perspective": "Tab bir köşe seçer, ok tuşları onu taşır, Shift × 10 · Esc kapatır", + "crop.corner_top_left": "Sol üst köşe", + "crop.corner_top_right": "Sağ üst köşe", + "crop.corner_bottom_right": "Sağ alt köşe", + "crop.corner_bottom_left": "Sol alt köşe", "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", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index a048a323f..c76a842df 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -262,6 +262,14 @@ "crop.state_done": "已裁剪", "crop.state_skipped": "已跳过", "crop.state_failed": "失败", + "crop.perspective": "透视", + "crop.perspective_tip": "透视:分别移动每个角,把倾斜的图片拉正", + "crop.perspective_pdf": "透视仅适用于图片,不适用于 PDF 页面", + "crop.hint_keys_perspective": "Tab 选择角,方向键移动,Shift 为 10 倍 · Esc 关闭", + "crop.corner_top_left": "左上角", + "crop.corner_top_right": "右上角", + "crop.corner_bottom_right": "右下角", + "crop.corner_bottom_left": "左下角", "preset.hide": "马赛克/模糊", "preset.hide.donut": "马赛克", "error.cannot_hide": "只能对图片打马赛克或模糊", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 51c831d80..db5bb5db8 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -262,6 +262,14 @@ "crop.state_done": "已裁剪", "crop.state_skipped": "已跳過", "crop.state_failed": "失敗", + "crop.perspective": "透視", + "crop.perspective_tip": "透視:分別移動每個角,把傾斜的圖片拉正", + "crop.perspective_pdf": "透視僅適用於圖片,不適用於 PDF 頁面", + "crop.hint_keys_perspective": "Tab 選擇角,方向鍵移動,Shift 為 10 倍 · Esc 關閉", + "crop.corner_top_left": "左上角", + "crop.corner_top_right": "右上角", + "crop.corner_bottom_right": "右下角", + "crop.corner_bottom_left": "左下角", "preset.hide": "馬賽克/模糊", "preset.hide.donut": "馬賽克", "error.cannot_hide": "只能對圖片加上馬賽克或模糊", diff --git a/src/Filee.App/Controls/CropCanvas.cs b/src/Filee.App/Controls/CropCanvas.cs index 3b0f358bc..aa44f251f 100644 --- a/src/Filee.App/Controls/CropCanvas.cs +++ b/src/Filee.App/Controls/CropCanvas.cs @@ -1,7 +1,9 @@ // The crop overlay's stage: the picture on a dimmed backdrop with soft accent glows, the part outside the frame // frosted (a tiny copy of the preview drawn large, so nothing is decoded again while dragging), the frame in the // accent colour with round handles, a rule-of-thirds grid that fades in while dragging and a size chip that follows -// the frame. Programmatic changes (reset, ratio, a new picture) ease in; dragging follows the pointer directly. +// the frame. Programmatic changes (reset, ratio, a new picture) ease in; dragging follows the pointer directly. In the +// perspective mode the frame gives way to four corners that move on their own (by pointer or keyboard), joined by the +// outline of the quadrilateral to straighten, with the size chip showing the straightened size. using System.Diagnostics; using System.Globalization; @@ -41,6 +43,15 @@ public sealed class CropCanvas : Control public static readonly StyledProperty AspectRatioProperty = AvaloniaProperty.Register(nameof(AspectRatio), 0); + public static readonly StyledProperty IsPerspectiveProperty = + AvaloniaProperty.Register(nameof(IsPerspective)); + + public static readonly StyledProperty NormalizedCornersProperty = + AvaloniaProperty.Register(nameof(NormalizedCorners), CropQuad.Full); + + public static readonly StyledProperty SelectedCornerProperty = + AvaloniaProperty.Register(nameof(SelectedCorner)); + /// Room kept free around the picture for the floating chips and the toolbar. public static readonly StyledProperty ImagePaddingProperty = AvaloniaProperty.Register(nameof(ImagePadding)); @@ -96,13 +107,20 @@ public double AspectRatio 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); } + public bool IsPerspective { get => GetValue(IsPerspectiveProperty); set => SetValue(IsPerspectiveProperty, value); } + public CropQuad NormalizedCorners { get => GetValue(NormalizedCornersProperty); set => SetValue(NormalizedCornersProperty, value); } + public int SelectedCorner { get => GetValue(SelectedCornerProperty); set => SetValue(SelectedCornerProperty, value); } public event Action? CropRectChanged; + public event Action? CornersChanged; + public event Action? CornerPressed; private CropHandle _activeHandle = CropHandle.None; + private IPointer? _dragPointer; private CropHandle _hoverHandle = CropHandle.None; private Point _dragStartPoint; private Rect _dragStartCrop; + private CropQuad _dragStartCorners; // The frame being moved sits on the picture's vertical / horizontal centre line: its guide is drawn. private bool _snappedX, _snappedY; @@ -154,7 +172,8 @@ private static double SafeClamp(double val, double min, double max) => static CropCanvas() { AffectsRender(SourceImageProperty, NormalizedCropRectProperty, AspectRatioProperty, ImagePaddingProperty, - SizeTextProperty, AccentBrushProperty, StageBrushProperty, ChipBackgroundProperty, ChipForegroundProperty); + SizeTextProperty, AccentBrushProperty, StageBrushProperty, ChipBackgroundProperty, ChipForegroundProperty, + IsPerspectiveProperty, NormalizedCornersProperty, SelectedCornerProperty); FocusableProperty.OverrideDefaultValue(false); } @@ -167,6 +186,32 @@ public void ResetCrop(Rect? frame = null) SetRect(frame ?? new Rect(0, 0, 1, 1), animate: true); } + /// Starts the corners over without changing the focused corner. + public void ResetCorners() + { + var same = NormalizedCorners == CropQuad.Full; + NormalizedCorners = CropQuad.Full; + if (same) + CornersChanged?.Invoke(CropQuad.Full); + InvalidateVisual(); + } + + /// Moves the selected corner by whole pixels of the picture. + public void NudgeCorner(int dx, int dy) + { + var size = PixelSize; + if (SourceImage is null || size.Width <= 0 || size.Height <= 0) + return; + NormalizedCorners = CropMath.NudgeCorner(NormalizedCorners, SelectedCorner, dx, dy, size.Width, size.Height); + } + + internal Point CornerScreenPoint(int corner) + { + var image = CalculateImageBounds(); + var p = NormalizedCorners[corner]; + return new Point(image.X + p.X * image.Width, image.Y + p.Y * image.Height); + } + public void SetAspectRatio(double ratio) { AspectRatio = ratio; @@ -297,6 +342,12 @@ internal void FinishAnimations() protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { + // A drag pressed in one mode or on one picture means nothing in the other (a frame body is no corner, a + // corner's delta would move the frame being restored or the next picture's fresh corners): let go of it. + if ((change.Property == IsPerspectiveProperty || change.Property == SourceImageProperty) + && _activeHandle != CropHandle.None) + _dragPointer?.Capture(null); + if (change.Property == NormalizedCropRectProperty) { var now = Now(); @@ -315,6 +366,10 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang } CropRectChanged?.Invoke(change.GetNewValue()); } + else if (change.Property == NormalizedCornersProperty) + { + CornersChanged?.Invoke(change.GetNewValue()); + } else if (change.Property == SourceImageProperty) { _soft?.Dispose(); @@ -406,6 +461,34 @@ public override void Render(DrawingContext context) using (context.PushOpacity(IsDark ? 0.62 : 0.58)) context.FillRectangle(StageBrush ?? Brushes.Gray, imgBounds); + if (IsPerspective) + { + var polygon = new StreamGeometry(); + using (var path = polygon.Open()) + { + path.BeginFigure(CornerScreenPoint(0), true); + for (var i = 1; i < 4; i++) + path.LineTo(CornerScreenPoint(i)); + path.EndFigure(true); + } + using (context.PushGeometryClip(polygon)) + using (context.PushRenderOptions(new RenderOptions { BitmapInterpolationMode = BitmapInterpolationMode.HighQuality })) + context.DrawImage(SourceImage, source, imgBounds); + var accent = AccentBrush ?? Brushes.MediumPurple; + context.DrawGeometry(null, FrameShadowPen, polygon); + context.DrawGeometry(null, new Pen(accent, 2), polygon); + var pop = EaseOutBack(_handlePop.Value(now)); + for (var i = 0; i < 4; i++) + { + var radius = 6.5 + (CornerHandle(i) == _poppedHandle ? 2.5 * pop : 0); + DrawDot(context, accent, CornerScreenPoint(i), radius); + if (i == SelectedCorner) + context.DrawEllipse(null, new Pen(accent, 2), CornerScreenPoint(i), radius + 5, radius + 5); + } + DrawSizeChip(context, polygon.Bounds, imgBounds, bounds); + return; + } + // Inside: the picture itself. var crop = ToScreen(ShownCropRect, imgBounds); using (context.PushClip(crop)) @@ -523,6 +606,17 @@ internal static void DrawDot(DrawingContext context, IBrush accent, Point center context.DrawEllipse(accent, HandleRingPen, center, radius, radius); } + /// The corner index (TopLeft, TopRight, BottomRight, BottomLeft) of a perspective corner handle. + private static int CornerOf(CropHandle handle) => handle switch + { + CropHandle.TopLeft => 0, + CropHandle.TopRight => 1, + CropHandle.BottomRight => 2, + CropHandle.BottomLeft => 3, + // Perspective hit-testing only hands out corners, and a mode switch lets go of any other handle. + _ => throw new UnreachableException($"{handle} is not a corner handle."), + }; + private static Point HandlePoint(Rect crop, CropHandle handle) => handle switch { CropHandle.TopLeft => crop.TopLeft, @@ -653,6 +747,14 @@ private CropHandle HitTestHandle(Point pt, Rect crop, Rect imgBounds) { const double hitTol = 16.0; + if (IsPerspective) + { + for (var i = 0; i < 4; i++) + if (Distance(pt, CornerScreenPoint(i)) <= hitTol) + return CornerHandle(i); + return CropHandle.None; + } + if (Distance(pt, crop.TopLeft) <= hitTol) return CropHandle.TopLeft; if (Distance(pt, crop.TopRight) <= hitTol) return CropHandle.TopRight; if (Distance(pt, crop.BottomLeft) <= hitTol) return CropHandle.BottomLeft; @@ -670,6 +772,14 @@ private CropHandle HitTestHandle(Point pt, Rect crop, Rect imgBounds) 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 CropHandle CornerHandle(int corner) => corner switch + { + 0 => CropHandle.TopLeft, + 1 => CropHandle.TopRight, + 2 => CropHandle.BottomRight, + _ => CropHandle.BottomLeft, + }; + protected override void OnPointerExited(PointerEventArgs e) { base.OnPointerExited(e); @@ -705,6 +815,19 @@ protected override void OnPointerMoved(PointerEventArgs e) return; } + if (IsPerspective) + { + // The corner the pointer took hold of, not SelectedCorner: Tab can move the selection mid-drag. It moves + // by the pointer's travel, like the frame's handles, so grabbing it off-centre doesn't make it jump. + var corner = CornerOf(_activeHandle); + var from = _dragStartCorners[corner]; + var target = new Point( + from.X + (pt.X - _dragStartPoint.X) / imgBounds.Width, + from.Y + (pt.Y - _dragStartPoint.Y) / imgBounds.Height); + NormalizedCorners = CropMath.MoveCorner(NormalizedCorners, corner, target, PixelSize.Width, PixelSize.Height); + return; + } + // Active manual selection drag if (_activeHandle == CropHandle.CreateNew) { @@ -923,14 +1046,27 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) if (_activeHandle != CropHandle.None) { + if (IsPerspective) + { + var corner = CornerOf(_activeHandle); + SelectedCorner = corner; + CornerPressed?.Invoke(corner); + } _dragStartPoint = pt; _dragStartCrop = crop; + _dragStartCorners = NormalizedCorners; _snappedX = _snappedY = false; e.Pointer.Capture(this); + _dragPointer = e.Pointer; e.Handled = true; PopHandle(_activeHandle); SetGrid(true); } + else if (IsPerspective && imgBounds.Contains(pt)) + { + // A press that misses the corners still lands on the picture: it isn't the stage that moves the window. + e.Handled = true; + } } protected override void OnPointerReleased(PointerReleasedEventArgs e) @@ -939,6 +1075,7 @@ protected override void OnPointerReleased(PointerReleasedEventArgs e) if (_activeHandle != CropHandle.None) { _activeHandle = CropHandle.None; + _dragPointer = null; e.Pointer.Capture(null); e.Handled = true; SetGrid(false); @@ -952,6 +1089,7 @@ protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) if (_activeHandle != CropHandle.None) { _activeHandle = CropHandle.None; + _dragPointer = null; SetGrid(false); PopHandle(CropHandle.None); } @@ -959,6 +1097,8 @@ protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) protected override void OnPointerWheelChanged(PointerWheelEventArgs e) { + if (IsPerspective) + return; base.OnPointerWheelChanged(e); var imgBounds = CalculateImageBounds(); if (imgBounds.Width <= 0 || imgBounds.Height <= 0) return; diff --git a/src/Filee.App/Controls/CropMath.cs b/src/Filee.App/Controls/CropMath.cs index 55252f031..a4a01ff80 100644 --- a/src/Filee.App/Controls/CropMath.cs +++ b/src/Filee.App/Controls/CropMath.cs @@ -1,12 +1,113 @@ // The crop overlay's arithmetic without a window: the ratios of the aspect dial, keyboard nudges, the frame's size in -// pixels, where the size chip sits next to the frame and where the aspect dial opens. +// pixels, where the size chip sits next to the frame and where the aspect dial opens. For the perspective mode it holds +// the four corners (CropQuad), which shapes they may form and how they move, the straightened output size and whether +// the corners still form a plain rectangle. using Avalonia; namespace Filee.App.Controls; +/// Four normalized picture corners, clockwise from the top left. +public readonly record struct CropQuad(Point TopLeft, Point TopRight, Point BottomRight, Point BottomLeft) +{ + /// The whole picture. + public static CropQuad Full => new(new Point(0, 0), new Point(1, 0), new Point(1, 1), new Point(0, 1)); + + /// A corner in clockwise order, starting at the top left. + public Point this[int corner] => corner switch + { + 0 => TopLeft, + 1 => TopRight, + 2 => BottomRight, + 3 => BottomLeft, + _ => throw new ArgumentOutOfRangeException(nameof(corner)), + }; + + /// A copy with one corner replaced. + public CropQuad With(int corner, Point p) => corner switch + { + 0 => this with { TopLeft = p }, + 1 => this with { TopRight = p }, + 2 => this with { BottomRight = p }, + 3 => this with { BottomLeft = p }, + _ => throw new ArgumentOutOfRangeException(nameof(corner)), + }; +} + public static class CropMath { + /// Minimum clockwise turn in square original pixels. + public const double TurnEpsilon = 1e-6; + + /// Whether all corners are inside the picture and form a strictly convex clockwise quadrilateral. + public static bool IsValidQuad(CropQuad q, int width, int height) + { + for (var i = 0; i < 4; i++) + { + var p = q[i]; + if (!(p.X >= 0 && p.X <= 1 && p.Y >= 0 && p.Y <= 1)) + return false; + var next = q[(i + 1) % 4]; + var last = q[(i + 2) % 4]; + var cross = ((next.X - p.X) * width) * ((last.Y - next.Y) * height) + - ((next.Y - p.Y) * height) * ((last.X - next.X) * width); + if (!(cross > TurnEpsilon)) + return false; + } + return true; + } + + /// + /// Moves one corner, stopping it on the picture's edge when the target lies outside, and only when the resulting + /// quadrilateral is valid. + /// + public static CropQuad MoveCorner(CropQuad q, int corner, Point target, int width, int height) + { + var moved = q.With(corner, new Point(Math.Clamp(target.X, 0, 1), Math.Clamp(target.Y, 0, 1))); + return IsValidQuad(moved, width, height) ? moved : q; + } + + /// Snaps one corner to the original pixel grid and moves it by whole pixels. + public static CropQuad NudgeCorner(CropQuad q, int corner, int dx, int dy, int width, int height) + { + if (width <= 0 || height <= 0) + return q; + var p = q[corner]; + var target = new Point((Math.Round(p.X * width) + dx) / width, (Math.Round(p.Y * height) + dy) / height); + return MoveCorner(q, corner, target, width, height); + } + + /// Output size from the longer opposing sides in original pixels, at least 1 by 1. + public static PixelSize PerspectiveSize(CropQuad q, int width, int height) + { + double Length(Point a, Point b) => + Math.Sqrt(Math.Pow((a.X - b.X) * width, 2) + Math.Pow((a.Y - b.Y) * height, 2)); + + return new PixelSize( + Math.Max(1, (int)Math.Round(Math.Max(Length(q.TopLeft, q.TopRight), Length(q.BottomLeft, q.BottomRight)))), + Math.Max(1, (int)Math.Round(Math.Max(Length(q.TopLeft, q.BottomLeft), Length(q.TopRight, q.BottomRight))))); + } + + /// + /// The quadrilateral as a rectangle in original pixels when it already is one: sides along the picture's edges and + /// every corner on a whole pixel, as the corners are until they're dragged off the grid. Saving it is then a plain + /// crop, with no resampling. + /// + public static PixelRect? AxisAlignedRect(CropQuad q, int width, int height) + { + static int? Whole(double v) => Math.Abs(v - Math.Round(v)) < 1e-6 ? (int)Math.Round(v) : null; + var left = Whole(q.TopLeft.X * width); + var top = Whole(q.TopLeft.Y * height); + var right = Whole(q.BottomRight.X * width); + var bottom = Whole(q.BottomRight.Y * height); + if (left is not { } l || top is not { } t || right is not { } r || bottom is not { } b + || Whole(q.TopRight.X * width) != r || Whole(q.TopRight.Y * height) != t + || Whole(q.BottomLeft.X * width) != l || Whole(q.BottomLeft.Y * height) != b + || r <= l || b <= t) + return null; + return new PixelRect(l, t, r - l, b - t); + } + /// The aspect dial's choices, clockwise from the top. public static IReadOnlyList AspectModes => Modes; diff --git a/src/Filee.App/Services/CropService.cs b/src/Filee.App/Services/CropService.cs index 26030ece6..661267d56 100644 --- a/src/Filee.App/Services/CropService.cs +++ b/src/Filee.App/Services/CropService.cs @@ -2,6 +2,7 @@ using System.IO; using Avalonia.Media.Imaging; +using Filee.App.Controls; using Filee.Core.Localization; using Filee.Core.Presets; using Filee.Core.Settings; @@ -103,7 +104,8 @@ public static async Task CropFileAsync( ILocalizer loc, ILogger? log = null, int? originalWidth = null, - int? originalHeight = null) + int? originalHeight = null, + CropQuad? corners = null) { return await Task.Run(() => { @@ -147,13 +149,27 @@ public static async Task CropFileAsync( DdsChannelOrder.Correct(inputPath, image); image.AutoOrient(); - var clampedX = Math.Clamp(x, 0, Math.Max(0, (int)image.Width - 1)); - var clampedY = Math.Clamp(y, 0, Math.Max(0, (int)image.Height - 1)); - var clampedW = (uint)Math.Clamp(width, 1, Math.Max(1, (int)image.Width - clampedX)); - var clampedH = (uint)Math.Clamp(height, 1, Math.Max(1, (int)image.Height - clampedY)); + if (corners is { } quad) + { + // Corners still on a whole-pixel rectangle need no resampling, which would only soften the picture. + if (CropMath.AxisAlignedRect(quad, (int)image.Width, (int)image.Height) is { } rect) + { + image.Crop(new MagickGeometry(rect.X, rect.Y, (uint)rect.Width, (uint)rect.Height)); + image.ResetPage(); + } + else + Straighten(image, quad); + } + else + { + var clampedX = Math.Clamp(x, 0, Math.Max(0, (int)image.Width - 1)); + var clampedY = Math.Clamp(y, 0, Math.Max(0, (int)image.Height - 1)); + var clampedW = (uint)Math.Clamp(width, 1, Math.Max(1, (int)image.Width - clampedX)); + var clampedH = (uint)Math.Clamp(height, 1, Math.Max(1, (int)image.Height - clampedY)); - image.Crop(new MagickGeometry(clampedX, clampedY, clampedW, clampedH)); - image.ResetPage(); + image.Crop(new MagickGeometry(clampedX, clampedY, clampedW, clampedH)); + image.ResetPage(); + } // Drop EXIF thumbnail so viewers don't show the uncropped preview if (image.GetExifProfile() is { } exif && exif.ThumbnailLength > 0) @@ -181,6 +197,25 @@ public static async Task CropFileAsync( }); } + private static void Straighten(MagickImage image, CropQuad quad) + { + var size = CropMath.PerspectiveSize(quad, (int)image.Width, (int)image.Height); + var width = image.Width; + var height = image.Height; + var w = (uint)size.Width; + var h = (uint)size.Height; + image.VirtualPixelMethod = VirtualPixelMethod.Edge; + var settings = new DistortSettings(DistortMethod.Perspective) { Viewport = new MagickGeometry(0, 0, w, h) }; + image.Distort(settings, + [ + quad.TopLeft.X * width, quad.TopLeft.Y * height, 0, 0, + quad.TopRight.X * width, quad.TopRight.Y * height, w, 0, + quad.BottomRight.X * width, quad.BottomRight.Y * height, w, h, + quad.BottomLeft.X * width, quad.BottomLeft.Y * height, 0, h, + ]); + image.ResetPage(); + } + private static void PreserveDates(string inputPath, string outputPath, Preset preset, UserDataStore store, ILogger? log) { if (!preset.Output.KeepDates && !store.Settings.KeepFileDates) diff --git a/src/Filee.App/ViewModels/CropOverlayViewModel.cs b/src/Filee.App/ViewModels/CropOverlayViewModel.cs index 8a9ef5edd..8e60936e0 100644 --- a/src/Filee.App/ViewModels/CropOverlayViewModel.cs +++ b/src/Filee.App/ViewModels/CropOverlayViewModel.cs @@ -54,6 +54,32 @@ public sealed partial class CropOverlayViewModel : ObservableObject, IDisposable [ObservableProperty] private bool _isBusy; [ObservableProperty] private double _aspectRatio; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPerspectiveOn))] + private bool _isPerspective; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsPerspectiveOn), nameof(CanUsePerspective), nameof(PerspectiveToolTip))] + private bool _isPdf; + + public bool IsPerspectiveOn => IsPerspective && !IsPdf && IsEditing; + public bool CanUsePerspective => !IsPdf; + public string PerspectiveToolTip => _loc[IsPdf ? "crop.perspective_pdf" : "crop.perspective_tip"]; + + partial void OnIsPerspectiveChanged(bool value) + { + IsAspectDialOpen = false; + if (value) + { + CornersResetRequested?.Invoke(); + PerspectiveTurnedOn?.Invoke(); + } + else + { + UpdateDimensions(CurrentCropRect); + } + } + [ObservableProperty] [NotifyPropertyChangedFor(nameof(AspectLabel))] [NotifyPropertyChangedFor(nameof(AspectToolTip))] @@ -120,6 +146,7 @@ private void CustomChanged() /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsEditing))] + [NotifyPropertyChangedFor(nameof(IsPerspectiveOn))] private bool _isFinished; public bool IsEditing => !IsFinished; @@ -176,6 +203,8 @@ private void CustomChanged() /// The frame starts over: the whole picture, or the frame given (a ratio carried from the last picture). public event Action? ResetRequested; public event Action? AspectRatioChanged; + public event Action? CornersResetRequested; + public event Action? PerspectiveTurnedOn; /// /// The ratio the user picked, kept for the next pictures of the batch (#542), and its orientation; null while none @@ -185,6 +214,7 @@ private void CustomChanged() private bool _batchPortrait; public Rect CurrentCropRect { get; set; } = new(0.05, 0.05, 0.9, 0.9); + public CropQuad CurrentCorners { get; private set; } = CropQuad.Full; public CropOverlayViewModel(IReadOnlyList files, Preset preset, ILocalizer loc, UserDataStore store, ILogger? log = null) { @@ -213,6 +243,7 @@ public async Task LoadFileAsync(int index) CurrentIndex = index; var path = _files[index]; CurrentFileName = Path.GetFileName(path); + IsPdf = Path.GetExtension(path).Equals(".pdf", StringComparison.OrdinalIgnoreCase); CounterText = _loc.Format("crop.page_counter", index + 1, _files.Count); SaveText = _loc[index < _files.Count - 1 ? "crop.save_next" : "crop.save"]; Queue[index].State = CropItemState.Current; @@ -244,6 +275,8 @@ public async Task LoadFileAsync(int index) var frame = CropMath.Centered(AspectRatio, origW, origH); UpdateDimensions(frame); ResetRequested?.Invoke(frame); + if (IsPerspectiveOn) + CornersResetRequested?.Invoke(); } catch (Exception ex) { @@ -272,10 +305,21 @@ public async Task LoadFileAsync(int index) public void UpdateDimensions(Rect norm) { CurrentCropRect = norm; + if (IsPerspectiveOn) + return; var size = CropMath.PixelSize(norm, OriginalWidth, OriginalHeight); DimensionsText = $"{size.Width} × {size.Height} px"; } + public void UpdateCorners(CropQuad corners) + { + CurrentCorners = corners; + if (!IsPerspectiveOn) + return; + var size = CropMath.PerspectiveSize(corners, OriginalWidth, OriginalHeight); + DimensionsText = $"{size.Width} × {size.Height} px"; + } + [RelayCommand] public async Task CropAndSaveAsync() { @@ -296,7 +340,7 @@ public async Task CropAndSaveAsync() try { var result = await CropService.CropFileAsync(path, x, y, w, h, _preset, _store, _loc, _log, - OriginalWidth, OriginalHeight); + OriginalWidth, OriginalHeight, corners: IsPerspectiveOn ? CurrentCorners : null); if (result.Status == CropStatus.Success && result.OutputPath is not null) { _outputs.Add(result.OutputPath); @@ -353,6 +397,11 @@ public async Task SkipAsync() [RelayCommand] public void Reset() { + if (IsPerspectiveOn) + { + CornersResetRequested?.Invoke(); + return; + } _batchAspect = null; // the next pictures start free again SelectedAspect = "free"; IsPortrait = OriginalHeight > OriginalWidth; @@ -495,6 +544,7 @@ private void OnLanguageChanged(object? sender, EventArgs e) } OnPropertyChanged(nameof(AspectLabel)); OnPropertyChanged(nameof(AspectToolTip)); + OnPropertyChanged(nameof(PerspectiveToolTip)); OnPropertyChanged(nameof(OrientationText)); foreach (var item in Queue) item.RefreshTexts(); diff --git a/src/Filee.App/Views/CropOverlayWindow.axaml b/src/Filee.App/Views/CropOverlayWindow.axaml index c52e3c470..89dcdfa71 100644 --- a/src/Filee.App/Views/CropOverlayWindow.axaml +++ b/src/Filee.App/Views/CropOverlayWindow.axaml @@ -10,7 +10,7 @@ x:DataType="vm:CropOverlayViewModel" Title="{DynamicResource preset.crop}" Icon="avares://Filee/Assets/Icons/filee.ico" - Width="1040" Height="720" MinWidth="680" MinHeight="500" + Width="1040" Height="720" MinWidth="720" MinHeight="500" WindowDecorations="None" Background="Transparent" TransparencyLevelHint="Transparent" @@ -18,6 +18,7 @@ CanResize="True"> + M5 4L20 6L18 20L3 17Z M5 6L5 4L7 4L7 6Z M18 5L21 5L21 8L18 8Z M16 18L19 18L19 21L16 21Z M2 15L5 15L5 18L2 18Z M7 3.5h8.5A4.5 4.5 0 0 1 20 8v2.2h2.2l-3.7 4.3-3.7-4.3H17V8a1.5 1.5 0 0 0-1.5-1.5H7z M17 20.5H8.5A4.5 4.5 0 0 1 4 16v-2.2H1.8l3.7-4.3 3.7 4.3H7V16a1.5 1.5 0 0 0 1.5 1.5H17z 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 @@ -45,7 +46,7 @@ - - - + + + + +