From caa87e8dc5889d0163a21b4afd727c9861107f04 Mon Sep 17 00:00:00 2001 From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:48:39 +0900 Subject: [PATCH 1/2] feat(crop): add a perspective mode that straightens a tilted picture An icon-only perspective toggle next to the ratio button lets each of the four corners move on its own. Saving straightens the chosen quad into a rectangle whose width is the longer of the top and bottom edges and whose height is the longer of the left and right edges. Output path, format, quality and date rules are the same as the rectangle crop. - Corners cannot be moved into a crossing, concave or flat shape, or outside the picture; a refused move leaves the corner where it was. - Tab picks a corner, arrow keys move it by one pixel (Shift: ten), and each corner has a localized screen-reader name. - The ratio dial and custom ratio are off while the mode is on. Each next picture starts with the corners on its own frame and the mode stays on. PDFs keep the rectangle crop and the toggle explains why. - A corner follows the pointer's travel, so grabbing it off-centre doesn't make it jump. A drag ends when the mode or the picture changes under it, for the rectangle frame too. - The crop window's minimum width goes from 680 to 720 so the toolbar with the new toggle, a custom ratio and several files still fits. - Test pictures are drawn in code. Closes #699 --- src/Filee.App/Assets/i18n/ar.json | 8 + src/Filee.App/Assets/i18n/de.json | 8 + src/Filee.App/Assets/i18n/en.json | 8 + src/Filee.App/Assets/i18n/es.json | 8 + src/Filee.App/Assets/i18n/fr.json | 8 + src/Filee.App/Assets/i18n/hi.json | 8 + src/Filee.App/Assets/i18n/it.json | 8 + src/Filee.App/Assets/i18n/ja.json | 8 + src/Filee.App/Assets/i18n/ko.json | 8 + src/Filee.App/Assets/i18n/ne.json | 8 + src/Filee.App/Assets/i18n/pl.json | 8 + src/Filee.App/Assets/i18n/pt-BR.json | 8 + src/Filee.App/Assets/i18n/pt-PT.json | 8 + src/Filee.App/Assets/i18n/ru.json | 8 + src/Filee.App/Assets/i18n/th.json | 8 + src/Filee.App/Assets/i18n/tr.json | 8 + src/Filee.App/Assets/i18n/zh-CN.json | 8 + src/Filee.App/Assets/i18n/zh-TW.json | 8 + src/Filee.App/Controls/CropCanvas.cs | 140 +++- src/Filee.App/Controls/CropMath.cs | 76 ++ src/Filee.App/Services/CropService.cs | 40 +- .../ViewModels/CropOverlayViewModel.cs | 52 +- src/Filee.App/Views/CropOverlayWindow.axaml | 50 +- .../Views/CropOverlayWindow.axaml.cs | 44 +- tests/Filee.App.Tests/CropMathTests.cs | 102 +++ tests/Filee.App.Tests/CropOverlayTests.cs | 668 ++++++++++++++++++ tests/Filee.App.Tests/CropServiceTests.cs | 161 +++++ 27 files changed, 1461 insertions(+), 16 deletions(-) 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..c981eb91c 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..056d639fc 100644 --- a/src/Filee.App/Controls/CropCanvas.cs +++ b/src/Filee.App/Controls/CropCanvas.cs @@ -41,6 +41,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 +105,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 +170,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 +184,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 +340,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 +364,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 +459,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 +604,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 +745,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 +770,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 +813,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 +1044,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 +1073,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 +1087,7 @@ protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) if (_activeHandle != CropHandle.None) { _activeHandle = CropHandle.None; + _dragPointer = null; SetGrid(false); PopHandle(CropHandle.None); } @@ -959,6 +1095,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..5c271bcc4 100644 --- a/src/Filee.App/Controls/CropMath.cs +++ b/src/Filee.App/Controls/CropMath.cs @@ -5,8 +5,84 @@ 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 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, target); + 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 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..5cba4fee5 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,18 @@ 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) + 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 +188,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..533ab671b 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 @@ - - - + + + + - -