From 7fb6ee916de011fcd9b89c9a16dfbff107d513f2 Mon Sep 17 00:00:00 2001 From: lightscape-jm Date: Mon, 2 Nov 2026 15:19:30 -0600 Subject: [PATCH 1/4] feat(tools): Annotate tool Pen lines, arrows, boxes, highlights, text labels and numbered callouts drawn on pictures, saved as "{name} Annotated" through an ordinary conversion in the picture's own format. One SkiaSharp painter draws the marks for the window and the file, so the preview is the file. In the palette only; settings schema 59. Closes #877 Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 14 + src/Filee.App/Assets/i18n/ar.json | 24 + src/Filee.App/Assets/i18n/de.json | 24 + src/Filee.App/Assets/i18n/en.json | 24 + src/Filee.App/Assets/i18n/es.json | 24 + src/Filee.App/Assets/i18n/fr.json | 24 + src/Filee.App/Assets/i18n/hi.json | 24 + src/Filee.App/Assets/i18n/id.json | 24 + src/Filee.App/Assets/i18n/it.json | 24 + src/Filee.App/Assets/i18n/ja.json | 24 + src/Filee.App/Assets/i18n/ko.json | 24 + src/Filee.App/Assets/i18n/ne.json | 24 + src/Filee.App/Assets/i18n/pl.json | 24 + src/Filee.App/Assets/i18n/pt-BR.json | 24 + src/Filee.App/Assets/i18n/pt-PT.json | 24 + src/Filee.App/Assets/i18n/ru.json | 24 + src/Filee.App/Assets/i18n/th.json | 24 + src/Filee.App/Assets/i18n/tr.json | 24 + src/Filee.App/Assets/i18n/zh-CN.json | 24 + src/Filee.App/Assets/i18n/zh-TW.json | 24 + src/Filee.App/Controls/AnnotationCanvas.cs | 500 ++++++++++++++++++ src/Filee.App/Controls/AnnotationSet.cs | 166 ++++++ src/Filee.App/Services/PresetAvailability.cs | 4 + src/Filee.App/Services/WindowService.cs | 32 ++ src/Filee.App/ViewModels/AnnotateViewModel.cs | 302 +++++++++++ src/Filee.App/Views/AnnotateWindow.axaml | 202 +++++++ src/Filee.App/Views/AnnotateWindow.axaml.cs | 155 ++++++ src/Filee.Core/Conversion/JobQueue.cs | 1 + src/Filee.Core/Presets/AnnotateTool.cs | 50 ++ src/Filee.Core/Presets/BuiltInData.cs | 17 + src/Filee.Core/Presets/Preset.cs | 95 +++- src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 6 + .../Annotations/AnnotationPainter.cs | 202 +++++++ src/Filee.Engines/Annotations/Annotator.cs | 42 ++ src/Filee.Engines/Magick/ImageEncoder.cs | 2 + tests/Filee.App.Tests/AnnotateWindowTests.cs | 477 +++++++++++++++++ .../ToolbarPaletteViewTests.cs | 8 + tests/Filee.Core.Tests/AnnotateToolTests.cs | 59 +++ tests/Filee.Engines.Tests/AnnotationTests.cs | 132 +++++ 40 files changed, 2922 insertions(+), 2 deletions(-) create mode 100644 src/Filee.App/Controls/AnnotationCanvas.cs create mode 100644 src/Filee.App/Controls/AnnotationSet.cs create mode 100644 src/Filee.App/ViewModels/AnnotateViewModel.cs create mode 100644 src/Filee.App/Views/AnnotateWindow.axaml create mode 100644 src/Filee.App/Views/AnnotateWindow.axaml.cs create mode 100644 src/Filee.Core/Presets/AnnotateTool.cs create mode 100644 src/Filee.Engines/Annotations/AnnotationPainter.cs create mode 100644 src/Filee.Engines/Annotations/Annotator.cs create mode 100644 tests/Filee.App.Tests/AnnotateWindowTests.cs create mode 100644 tests/Filee.Core.Tests/AnnotateToolTests.cs create mode 100644 tests/Filee.Engines.Tests/AnnotationTests.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a327812df..8135e89de 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -148,6 +148,20 @@ A group is one slice that opens up to six non-group actions. Its members may be editor offers both kinds, and `UserDataStore.EnsureConsistency` removes missing, duplicate, nested or excess members without excluding tools. +## Annotate tool + +"Annotate" (`tool-annotate`, palette only, settings schema 59) draws pen lines, arrows, boxes, highlights, text and +numbers on pictures (`ImageOptions.Annotations`): points are parts of the picture, sizes parts of its shorter side. + +- `Engines/Annotations/AnnotationPainter` (SkiaSharp) draws the marks. The window's `AnnotationCanvas` calls it on + Avalonia's own Skia canvas (a custom draw operation through `ISkiaSharpApiLeaseFeature`) and `Annotator` on a layer + the size of the file, laid over the picture in `ImageEncoder.ApplyOptions` after hidden areas: the preview is the + file. Text uses the system's bold font, or one the system finds for letters it lacks. +- `AnnotationSet` keeps the marks, the selection and undo / redo (a drag is one step). A mark just drawn isn't selected: + a colour picked next goes to the next mark; with Select, colour and width restyle the selected one. +- `AnnotateTool.ConversionOf` (Core) turns the tool into a conversion in the picture's own format (as Pixelate / blur), + run by the job queue; `ReworksPixels` includes the marks. + ## Crop overlay and PDF page editor The image Crop tool (`tool-crop`) and PDF Crop (`tool-pdf-crop`) open `Views/CropOverlayWindow` instead of running a job: diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index ae5e9d33a..d522eebdb 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -373,6 +373,7 @@ "preset.rotate_180": "تدوير 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "انعكاس", + "preset.annotate": "تعليقات", "preset.flip_horizontal": "انعكاس أفقي", "preset.flip_horizontal.short": "أفقي", "preset.flip_vertical": "انعكاس عمودي", @@ -900,6 +901,29 @@ "presets.conflict.Rename": "إضافة رقم", "presets.conflict.Overwrite": "استبداله", "presets.conflict.Skip": "تخطٍّ", + "annotate.select": "تحديد", + "annotate.select_hint": "حدّد علامة أو انقلها أو غيّر حجمها (V)", + "annotate.pen": "قلم", + "annotate.pen_hint": "ارسم بحرية (P)", + "annotate.arrow": "سهم", + "annotate.arrow_hint": "ارسم سهمًا من الذيل إلى الرأس (A)", + "annotate.box": "مربع", + "annotate.box_hint": "ارسم إطارًا (B)", + "annotate.highlight": "تمييز", + "annotate.highlight_hint": "ميّز بلون شفاف (H)", + "annotate.text": "نص", + "annotate.text_hint": "انقر واكتب تسمية (T)", + "annotate.number": "رقم", + "annotate.number_hint": "انقر لإضافة الرقم التالي (N)", + "annotate.thin": "رفيع", + "annotate.medium": "متوسط", + "annotate.thick": "سميك", + "annotate.undo": "تراجع (Ctrl+Z)", + "annotate.redo": "إعادة (Ctrl+Shift+Z)", + "annotate.remove": "إزالة العلامة المحددة (Del)", + "annotate.keys": "V للتحديد · P A B H T N لاختيار أداة · Del للإزالة · Ctrl+Z للتراجع · Esc للإغلاق", + "annotate.save": "حفظ", + "annotate.save_next": "حفظ والتالي", "presets.editor_title": "تعديل الإعداد المسبق", "presets.delete_confirm": "هل تريد حذف «{0}»؟ سيُزال أيضًا من جميع أشرطة الأدوات والمجموعات.", "presets.name_required": "أدخل اسمًا", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index c047aa517..a7edb24e2 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -373,6 +373,7 @@ "preset.rotate_180": "Um 180° drehen", "preset.rotate_180.short": "180°", "preset.group_flip": "Spiegeln", + "preset.annotate": "Beschriften", "preset.flip_horizontal": "Horizontal spiegeln", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Vertikal spiegeln", @@ -900,6 +901,29 @@ "presets.conflict.Rename": "Nummer anhängen", "presets.conflict.Overwrite": "Ersetzen", "presets.conflict.Skip": "Überspringen", + "annotate.select": "Auswählen", + "annotate.select_hint": "Markierung auswählen, verschieben oder skalieren (V)", + "annotate.pen": "Stift", + "annotate.pen_hint": "Freihand zeichnen (P)", + "annotate.arrow": "Pfeil", + "annotate.arrow_hint": "Pfeil vom Ende zur Spitze zeichnen (A)", + "annotate.box": "Rahmen", + "annotate.box_hint": "Rahmen zeichnen (B)", + "annotate.highlight": "Textmarker", + "annotate.highlight_hint": "Mit durchscheinender Farbe markieren (H)", + "annotate.text": "Text", + "annotate.text_hint": "Klicken und Beschriftung tippen (T)", + "annotate.number": "Nummer", + "annotate.number_hint": "Klicken für die nächste Nummer (N)", + "annotate.thin": "Dünn", + "annotate.medium": "Mittel", + "annotate.thick": "Dick", + "annotate.undo": "Rückgängig (Strg+Z)", + "annotate.redo": "Wiederholen (Strg+Umschalt+Z)", + "annotate.remove": "Ausgewählte Markierung entfernen (Entf)", + "annotate.keys": "V wählt aus · P A B H T N wählen ein Werkzeug · Entf entfernt · Strg+Z macht rückgängig · Esc schließt", + "annotate.save": "Speichern", + "annotate.save_next": "Speichern & weiter", "presets.editor_title": "Vorlage bearbeiten", "presets.delete_confirm": "„{0}“ löschen? Die Vorlage wird auch aus allen Werkzeugleisten und Gruppen entfernt.", "presets.name_required": "Namen eingeben", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index d74265840..71959fe9b 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -383,6 +383,7 @@ "preset.rotate_180": "Rotate 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Flip", + "preset.annotate": "Annotate", "preset.flip_horizontal": "Flip horizontal", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Flip vertical", @@ -915,6 +916,29 @@ "presets.conflict.Rename": "Add a number", "presets.conflict.Overwrite": "Replace it", "presets.conflict.Skip": "Skip", + "annotate.select": "Select", + "annotate.select_hint": "Select, move or resize a mark (V)", + "annotate.pen": "Pen", + "annotate.pen_hint": "Draw freehand (P)", + "annotate.arrow": "Arrow", + "annotate.arrow_hint": "Draw an arrow, tail to tip (A)", + "annotate.box": "Box", + "annotate.box_hint": "Draw a frame (B)", + "annotate.highlight": "Highlight", + "annotate.highlight_hint": "Mark with a see-through colour (H)", + "annotate.text": "Text", + "annotate.text_hint": "Click and type a label (T)", + "annotate.number": "Number", + "annotate.number_hint": "Click to add the next number (N)", + "annotate.thin": "Thin", + "annotate.medium": "Medium", + "annotate.thick": "Thick", + "annotate.undo": "Undo (Ctrl+Z)", + "annotate.redo": "Redo (Ctrl+Shift+Z)", + "annotate.remove": "Remove the selected mark (Del)", + "annotate.keys": "V selects · P A B H T N pick a tool · Del removes · Ctrl+Z undoes · Esc closes", + "annotate.save": "Save", + "annotate.save_next": "Save & next", "presets.editor_title": "Edit preset", "presets.delete_confirm": "Delete “{0}”? It will also be removed from every toolbar and group.", "presets.name_required": "Enter a name", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index bd3897153..967f2dc55 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -373,6 +373,7 @@ "preset.rotate_180": "Rotar 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Voltear", + "preset.annotate": "Anotar", "preset.flip_horizontal": "Volteo horizontal", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Volteo vertical", @@ -900,6 +901,29 @@ "presets.conflict.Rename": "Añadir un número", "presets.conflict.Overwrite": "Reemplazarlo", "presets.conflict.Skip": "Omitir", + "annotate.select": "Seleccionar", + "annotate.select_hint": "Seleccionar, mover o redimensionar una marca (V)", + "annotate.pen": "Lápiz", + "annotate.pen_hint": "Dibujar a mano alzada (P)", + "annotate.arrow": "Flecha", + "annotate.arrow_hint": "Dibujar una flecha de la cola a la punta (A)", + "annotate.box": "Recuadro", + "annotate.box_hint": "Dibujar un recuadro (B)", + "annotate.highlight": "Resaltador", + "annotate.highlight_hint": "Marcar con un color transparente (H)", + "annotate.text": "Texto", + "annotate.text_hint": "Haz clic y escribe una etiqueta (T)", + "annotate.number": "Número", + "annotate.number_hint": "Haz clic para añadir el siguiente número (N)", + "annotate.thin": "Fino", + "annotate.medium": "Medio", + "annotate.thick": "Grueso", + "annotate.undo": "Deshacer (Ctrl+Z)", + "annotate.redo": "Rehacer (Ctrl+Mayús+Z)", + "annotate.remove": "Quitar la marca seleccionada (Supr)", + "annotate.keys": "V selecciona · P A B H T N eligen herramienta · Supr quita · Ctrl+Z deshace · Esc cierra", + "annotate.save": "Guardar", + "annotate.save_next": "Guardar y siguiente", "presets.editor_title": "Editar ajuste preestablecido", "presets.delete_confirm": "¿Eliminar «{0}»? También se quitará de todos los menús circulares y grupos.", "presets.name_required": "Escribe un nombre", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 9488fe349..f61b9987c 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -383,6 +383,7 @@ "preset.rotate_180": "Pivoter de 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Retourner", + "preset.annotate": "Annoter", "preset.flip_horizontal": "Retourner horizontalement", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Retourner verticalement", @@ -915,6 +916,29 @@ "presets.conflict.Rename": "Ajouter un numéro", "presets.conflict.Overwrite": "Le remplacer", "presets.conflict.Skip": "Ignorer", + "annotate.select": "Sélection", + "annotate.select_hint": "Sélectionner, déplacer ou redimensionner une marque (V)", + "annotate.pen": "Stylo", + "annotate.pen_hint": "Dessiner à main levée (P)", + "annotate.arrow": "Flèche", + "annotate.arrow_hint": "Tracer une flèche, de la base à la pointe (A)", + "annotate.box": "Cadre", + "annotate.box_hint": "Tracer un cadre (B)", + "annotate.highlight": "Surligneur", + "annotate.highlight_hint": "Marquer d'une couleur transparente (H)", + "annotate.text": "Texte", + "annotate.text_hint": "Cliquer et taper une étiquette (T)", + "annotate.number": "Numéro", + "annotate.number_hint": "Cliquer pour ajouter le numéro suivant (N)", + "annotate.thin": "Fin", + "annotate.medium": "Moyen", + "annotate.thick": "Épais", + "annotate.undo": "Annuler (Ctrl+Z)", + "annotate.redo": "Rétablir (Ctrl+Maj+Z)", + "annotate.remove": "Supprimer la marque sélectionnée (Suppr)", + "annotate.keys": "V sélectionne · P A B H T N choisissent un outil · Suppr supprime · Ctrl+Z annule · Échap ferme", + "annotate.save": "Enregistrer", + "annotate.save_next": "Enregistrer et suivant", "presets.editor_title": "Modifier le préréglage", "presets.delete_confirm": "Supprimer « {0} » ? Il sera aussi retiré de tous les menus circulaires et groupes.", "presets.name_required": "Saisissez un nom", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 525f6bfe1..51eae9b58 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -373,6 +373,7 @@ "preset.rotate_180": "180° घुमाएँ", "preset.rotate_180.short": "180°", "preset.group_flip": "पलटें", + "preset.annotate": "टिप्पणी", "preset.flip_horizontal": "आड़ा पलटें", "preset.flip_horizontal.short": "आड़ा", "preset.flip_vertical": "खड़ा पलटें", @@ -900,6 +901,29 @@ "presets.conflict.Rename": "नंबर जोड़ें", "presets.conflict.Overwrite": "बदल दें", "presets.conflict.Skip": "छोड़ दें", + "annotate.select": "चुनें", + "annotate.select_hint": "निशान चुनें, खिसकाएँ या आकार बदलें (V)", + "annotate.pen": "पेन", + "annotate.pen_hint": "हाथ से बनाएँ (P)", + "annotate.arrow": "तीर", + "annotate.arrow_hint": "पूँछ से नोक तक तीर बनाएँ (A)", + "annotate.box": "बॉक्स", + "annotate.box_hint": "फ़्रेम बनाएँ (B)", + "annotate.highlight": "हाइलाइट", + "annotate.highlight_hint": "पारदर्शी रंग से निशान लगाएँ (H)", + "annotate.text": "टेक्स्ट", + "annotate.text_hint": "क्लिक करके लेबल लिखें (T)", + "annotate.number": "नंबर", + "annotate.number_hint": "अगला नंबर जोड़ने के लिए क्लिक करें (N)", + "annotate.thin": "पतला", + "annotate.medium": "मध्यम", + "annotate.thick": "मोटा", + "annotate.undo": "पूर्ववत (Ctrl+Z)", + "annotate.redo": "फिर से करें (Ctrl+Shift+Z)", + "annotate.remove": "चुना निशान हटाएँ (Del)", + "annotate.keys": "V चुनता है · P A B H T N टूल चुनते हैं · Del हटाता है · Ctrl+Z पूर्ववत · Esc बंद करता है", + "annotate.save": "सहेजें", + "annotate.save_next": "सहेजें और अगला", "presets.editor_title": "प्रीसेट संपादित करें", "presets.delete_confirm": "“{0}” हटाएँ? यह हर टूलबार और समूह से भी हट जाएगा।", "presets.name_required": "नाम दर्ज करें", diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json index 32505be44..ba6d4befb 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -373,6 +373,7 @@ "preset.rotate_180": "Putar 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Balik", + "preset.annotate": "Anotasi", "preset.flip_horizontal": "Balik horizontal", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Balik vertikal", @@ -900,6 +901,29 @@ "presets.conflict.Rename": "Tambahkan nomor", "presets.conflict.Overwrite": "Timpa file", "presets.conflict.Skip": "Lewati", + "annotate.select": "Pilih", + "annotate.select_hint": "Pilih, pindahkan, atau ubah ukuran tanda (V)", + "annotate.pen": "Pena", + "annotate.pen_hint": "Gambar bebas (P)", + "annotate.arrow": "Panah", + "annotate.arrow_hint": "Gambar panah, dari pangkal ke ujung (A)", + "annotate.box": "Kotak", + "annotate.box_hint": "Gambar bingkai (B)", + "annotate.highlight": "Sorot", + "annotate.highlight_hint": "Tandai dengan warna tembus pandang (H)", + "annotate.text": "Teks", + "annotate.text_hint": "Klik dan ketik label (T)", + "annotate.number": "Nomor", + "annotate.number_hint": "Klik untuk menambah nomor berikutnya (N)", + "annotate.thin": "Tipis", + "annotate.medium": "Sedang", + "annotate.thick": "Tebal", + "annotate.undo": "Urungkan (Ctrl+Z)", + "annotate.redo": "Ulangi (Ctrl+Shift+Z)", + "annotate.remove": "Hapus tanda yang dipilih (Del)", + "annotate.keys": "V memilih · P A B H T N memilih alat · Del menghapus · Ctrl+Z urungkan · Esc menutup", + "annotate.save": "Simpan", + "annotate.save_next": "Simpan & Berikutnya", "presets.editor_title": "Edit preset", "presets.delete_confirm": "Hapus “{0}”? Preset ini juga akan dihapus dari semua toolbar dan grup.", "presets.name_required": "Masukkan nama", diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json index 2bb460097..4d8064cfc 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -373,6 +373,7 @@ "preset.rotate_180": "Ruota di 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Capovolgi", +"preset.annotate": "Annota", "preset.flip_horizontal": "Capovolgi orizzontalmente", "preset.flip_horizontal.short": "Orizzontale", "preset.flip_vertical": "Capovolgi verticalmente", @@ -900,6 +901,29 @@ "presets.conflict.Rename": "Aggiungi un numero", "presets.conflict.Overwrite": "Sostituiscilo", "presets.conflict.Skip": "Salta", +"annotate.select": "Seleziona", +"annotate.select_hint": "Seleziona, sposta o ridimensiona un segno (V)", +"annotate.pen": "Penna", +"annotate.pen_hint": "Disegna a mano libera (P)", +"annotate.arrow": "Freccia", +"annotate.arrow_hint": "Disegna una freccia, dalla coda alla punta (A)", +"annotate.box": "Riquadro", +"annotate.box_hint": "Disegna una cornice (B)", +"annotate.highlight": "Evidenzia", +"annotate.highlight_hint": "Segna con un colore trasparente (H)", +"annotate.text": "Testo", +"annotate.text_hint": "Fai clic e scrivi un'etichetta (T)", +"annotate.number": "Numero", +"annotate.number_hint": "Fai clic per aggiungere il numero successivo (N)", +"annotate.thin": "Sottile", +"annotate.medium": "Medio", +"annotate.thick": "Spesso", +"annotate.undo": "Annulla (Ctrl+Z)", +"annotate.redo": "Ripeti (Ctrl+Maiusc+Z)", +"annotate.remove": "Rimuovi il segno selezionato (Canc)", +"annotate.keys": "V seleziona · P A B H T N scelgono uno strumento · Canc rimuove · Ctrl+Z annulla · Esc chiude", +"annotate.save": "Salva", +"annotate.save_next": "Salva e avanti", "presets.editor_title": "Modifica preset", "presets.delete_confirm": "Eliminare “{0}”? Verrà rimosso anche da ogni barra e gruppo.", "presets.name_required": "Inserisci un nome", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 40674bae2..e89b81f7a 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -373,6 +373,7 @@ "preset.rotate_180": "180°回転", "preset.rotate_180.short": "180°", "preset.group_flip": "反転", + "preset.annotate": "注釈", "preset.flip_horizontal": "水平方向に反転", "preset.flip_horizontal.short": "水平", "preset.flip_vertical": "垂直方向に反転", @@ -900,6 +901,29 @@ "presets.conflict.Rename": "連番を付ける", "presets.conflict.Overwrite": "上書き", "presets.conflict.Skip": "スキップ", + "annotate.select": "選択", + "annotate.select_hint": "マークを選択・移動・サイズ変更 (V)", + "annotate.pen": "ペン", + "annotate.pen_hint": "フリーハンドで描く (P)", + "annotate.arrow": "矢印", + "annotate.arrow_hint": "根元から先端へ矢印を描く (A)", + "annotate.box": "枠", + "annotate.box_hint": "枠を描く (B)", + "annotate.highlight": "ハイライト", + "annotate.highlight_hint": "透ける色で塗る (H)", + "annotate.text": "テキスト", + "annotate.text_hint": "クリックして文字を入力 (T)", + "annotate.number": "番号", + "annotate.number_hint": "クリックで次の番号を追加 (N)", + "annotate.thin": "細い", + "annotate.medium": "ふつう", + "annotate.thick": "太い", + "annotate.undo": "元に戻す (Ctrl+Z)", + "annotate.redo": "やり直し (Ctrl+Shift+Z)", + "annotate.remove": "選択したマークを削除 (Del)", + "annotate.keys": "V 選択 · P A B H T N ツール · Del 削除 · Ctrl+Z 元に戻す · Esc 閉じる", + "annotate.save": "保存", + "annotate.save_next": "保存して次へ", "presets.editor_title": "プリセットを編集", "presets.delete_confirm": "「{0}」を削除しますか?すべてのドーナツメニューとグループからも削除されます。", "presets.name_required": "名前を入力してください", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 82bd41c7f..76d81eaa1 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -383,6 +383,7 @@ "preset.rotate_180": "180° 회전", "preset.rotate_180.short": "180°", "preset.group_flip": "뒤집기", + "preset.annotate": "주석", "preset.flip_horizontal": "좌우 반전", "preset.flip_horizontal.short": "좌우", "preset.flip_vertical": "상하 반전", @@ -915,6 +916,29 @@ "presets.conflict.Rename": "번호 붙이기", "presets.conflict.Overwrite": "덮어쓰기", "presets.conflict.Skip": "건너뛰기", + "annotate.select": "선택", + "annotate.select_hint": "표시를 선택하고 옮기거나 크기 바꾸기 (V)", + "annotate.pen": "펜", + "annotate.pen_hint": "자유롭게 그리기 (P)", + "annotate.arrow": "화살표", + "annotate.arrow_hint": "꼬리에서 끝까지 화살표 그리기 (A)", + "annotate.box": "상자", + "annotate.box_hint": "테두리 그리기 (B)", + "annotate.highlight": "형광펜", + "annotate.highlight_hint": "비치는 색으로 칠하기 (H)", + "annotate.text": "텍스트", + "annotate.text_hint": "눌러서 글자 넣기 (T)", + "annotate.number": "번호", + "annotate.number_hint": "눌러서 다음 번호 넣기 (N)", + "annotate.thin": "가늘게", + "annotate.medium": "보통", + "annotate.thick": "굵게", + "annotate.undo": "되돌리기 (Ctrl+Z)", + "annotate.redo": "다시 실행 (Ctrl+Shift+Z)", + "annotate.remove": "선택한 표시 지우기 (Del)", + "annotate.keys": "V 선택 · P A B H T N 도구 고르기 · Del 지우기 · Ctrl+Z 되돌리기 · Esc 닫기", + "annotate.save": "저장", + "annotate.save_next": "저장하고 다음", "presets.editor_title": "프리셋 편집", "presets.delete_confirm": "“{0}”을(를) 삭제할까요? 모든 툴바와 묶음에서도 빠져요.", "presets.name_required": "이름을 입력하세요", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 4dc7a4ef7..41c4a7bda 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -373,6 +373,7 @@ "preset.rotate_180": "१८०° घुमाउनुहोस्", "preset.rotate_180.short": "१८०°", "preset.group_flip": "उल्ट्याउनुहोस्", + "preset.annotate": "टिप्पणी", "preset.flip_horizontal": "तेर्सो उल्ट्याउनुहोस्", "preset.flip_horizontal.short": "तेर्सो", "preset.flip_vertical": "ठाडो उल्ट्याउनुहोस्", @@ -900,6 +901,29 @@ "presets.conflict.Rename": "नम्बर थप्नुहोस्", "presets.conflict.Overwrite": "बदल्नुहोस् (अधिलेखन)", "presets.conflict.Skip": "छोड्नुहोस्", + "annotate.select": "छान्नुहोस्", + "annotate.select_hint": "चिन्ह छान्नुहोस्, सार्नुहोस् वा आकार बदल्नुहोस् (V)", + "annotate.pen": "कलम", + "annotate.pen_hint": "हातले कोर्नुहोस् (P)", + "annotate.arrow": "तीर", + "annotate.arrow_hint": "पुच्छरदेखि टुप्पोसम्म तीर कोर्नुहोस् (A)", + "annotate.box": "बाकस", + "annotate.box_hint": "फ्रेम कोर्नुहोस् (B)", + "annotate.highlight": "हाइलाइट", + "annotate.highlight_hint": "पारदर्शी रङले चिन्ह लगाउनुहोस् (H)", + "annotate.text": "पाठ", + "annotate.text_hint": "क्लिक गरेर लेबल लेख्नुहोस् (T)", + "annotate.number": "नम्बर", + "annotate.number_hint": "अर्को नम्बर थप्न क्लिक गर्नुहोस् (N)", + "annotate.thin": "पातलो", + "annotate.medium": "मध्यम", + "annotate.thick": "बाक्लो", + "annotate.undo": "पूर्ववत (Ctrl+Z)", + "annotate.redo": "फेरि गर्नुहोस् (Ctrl+Shift+Z)", + "annotate.remove": "छानिएको चिन्ह हटाउनुहोस् (Del)", + "annotate.keys": "V छान्छ · P A B H T N उपकरण छान्छन् · Del हटाउँछ · Ctrl+Z पूर्ववत · Esc बन्द गर्छ", + "annotate.save": "सुरक्षित गर्नुहोस्", + "annotate.save_next": "सुरक्षित गरेर अर्को", "presets.editor_title": "प्रिसेट सम्पादन गर्नुहोस्", "presets.delete_confirm": "“{0}” हटाउने? यो प्रत्येक टुलबार र समूहबाट पनि हटाइनेछ।", "presets.name_required": "नाम प्रविष्ट गर्नुहोस्", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 14c3ff321..ac8268b81 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -383,6 +383,7 @@ "preset.rotate_180": "Obróć o 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Odbij", + "preset.annotate": "Adnotacje", "preset.flip_horizontal": "Odbij w poziomie", "preset.flip_horizontal.short": "Poziomo", "preset.flip_vertical": "Odbij w pionie", @@ -915,6 +916,29 @@ "presets.conflict.Rename": "Dodaj numer", "presets.conflict.Overwrite": "Zastąp", "presets.conflict.Skip": "Pomiń", + "annotate.select": "Zaznacz", + "annotate.select_hint": "Zaznacz, przesuń lub zmień rozmiar znaku (V)", + "annotate.pen": "Pióro", + "annotate.pen_hint": "Rysuj odręcznie (P)", + "annotate.arrow": "Strzałka", + "annotate.arrow_hint": "Narysuj strzałkę od końca do grotu (A)", + "annotate.box": "Ramka", + "annotate.box_hint": "Narysuj ramkę (B)", + "annotate.highlight": "Zakreślacz", + "annotate.highlight_hint": "Zaznacz przezroczystym kolorem (H)", + "annotate.text": "Tekst", + "annotate.text_hint": "Kliknij i wpisz etykietę (T)", + "annotate.number": "Numer", + "annotate.number_hint": "Kliknij, by dodać kolejny numer (N)", + "annotate.thin": "Cienka", + "annotate.medium": "Średnia", + "annotate.thick": "Gruba", + "annotate.undo": "Cofnij (Ctrl+Z)", + "annotate.redo": "Ponów (Ctrl+Shift+Z)", + "annotate.remove": "Usuń zaznaczony znak (Del)", + "annotate.keys": "V zaznacza · P A B H T N wybierają narzędzie · Del usuwa · Ctrl+Z cofa · Esc zamyka", + "annotate.save": "Zapisz", + "annotate.save_next": "Zapisz i dalej", "presets.editor_title": "Edytuj ustawienie", "presets.delete_confirm": "Usunąć „{0}”? Zostanie też usunięte z każdego paska i grupy.", "presets.name_required": "Podaj nazwę", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index d898e7030..a5eaf0b80 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -383,6 +383,7 @@ "preset.rotate_180": "Girar 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Espelhar", + "preset.annotate": "Anotar", "preset.flip_horizontal": "Espelhar na horizontal", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Espelhar na vertical", @@ -915,6 +916,29 @@ "presets.conflict.Rename": "Adicionar um número", "presets.conflict.Overwrite": "Substituir", "presets.conflict.Skip": "Ignorar", + "annotate.select": "Selecionar", + "annotate.select_hint": "Selecionar, mover ou redimensionar uma marca (V)", + "annotate.pen": "Caneta", + "annotate.pen_hint": "Desenhar à mão livre (P)", + "annotate.arrow": "Seta", + "annotate.arrow_hint": "Desenhar uma seta da base à ponta (A)", + "annotate.box": "Caixa", + "annotate.box_hint": "Desenhar uma moldura (B)", + "annotate.highlight": "Marca-texto", + "annotate.highlight_hint": "Marcar com uma cor transparente (H)", + "annotate.text": "Texto", + "annotate.text_hint": "Clique e digite um rótulo (T)", + "annotate.number": "Número", + "annotate.number_hint": "Clique para adicionar o próximo número (N)", + "annotate.thin": "Fino", + "annotate.medium": "Médio", + "annotate.thick": "Grosso", + "annotate.undo": "Desfazer (Ctrl+Z)", + "annotate.redo": "Refazer (Ctrl+Shift+Z)", + "annotate.remove": "Remover a marca selecionada (Del)", + "annotate.keys": "V seleciona · P A B H T N escolhem a ferramenta · Del remove · Ctrl+Z desfaz · Esc fecha", + "annotate.save": "Salvar", + "annotate.save_next": "Salvar e próximo", "presets.editor_title": "Editar predefinição", "presets.delete_confirm": "Excluir “{0}”? Também será removida de todo menu e grupo.", "presets.name_required": "Digite um nome", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index 642555c3c..fb44fa8a3 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -383,6 +383,7 @@ "preset.rotate_180": "Rodar 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Inverter", + "preset.annotate": "Anotar", "preset.flip_horizontal": "Inverter na horizontal", "preset.flip_horizontal.short": "Horizontal", "preset.flip_vertical": "Inverter na vertical", @@ -915,6 +916,29 @@ "presets.conflict.Rename": "Adicionar um número", "presets.conflict.Overwrite": "Substituir", "presets.conflict.Skip": "Ignorar", + "annotate.select": "Selecionar", + "annotate.select_hint": "Selecionar, mover ou redimensionar uma marca (V)", + "annotate.pen": "Caneta", + "annotate.pen_hint": "Desenhar à mão livre (P)", + "annotate.arrow": "Seta", + "annotate.arrow_hint": "Desenhar uma seta da base à ponta (A)", + "annotate.box": "Caixa", + "annotate.box_hint": "Desenhar uma moldura (B)", + "annotate.highlight": "Marcador", + "annotate.highlight_hint": "Marcar com uma cor transparente (H)", + "annotate.text": "Texto", + "annotate.text_hint": "Clique e escreva uma etiqueta (T)", + "annotate.number": "Número", + "annotate.number_hint": "Clique para adicionar o número seguinte (N)", + "annotate.thin": "Fino", + "annotate.medium": "Médio", + "annotate.thick": "Grosso", + "annotate.undo": "Anular (Ctrl+Z)", + "annotate.redo": "Refazer (Ctrl+Shift+Z)", + "annotate.remove": "Remover a marca selecionada (Del)", + "annotate.keys": "V seleciona · P A B H T N escolhem a ferramenta · Del remove · Ctrl+Z anula · Esc fecha", + "annotate.save": "Guardar", + "annotate.save_next": "Guardar e seguinte", "presets.editor_title": "Editar predefinição", "presets.delete_confirm": "Eliminar «{0}»? Também será removida de todas as barras e grupos.", "presets.name_required": "Introduz um nome", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 6dde8d904..8ab2006e7 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -383,6 +383,7 @@ "preset.rotate_180": "Повернуть на 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "Отразить", + "preset.annotate": "Пометки", "preset.flip_horizontal": "Отразить по горизонтали", "preset.flip_horizontal.short": "По гориз.", "preset.flip_vertical": "Отразить по вертикали", @@ -915,6 +916,29 @@ "presets.conflict.Rename": "Добавить номер", "presets.conflict.Overwrite": "Заменить", "presets.conflict.Skip": "Пропустить", + "annotate.select": "Выбор", + "annotate.select_hint": "Выбрать, переместить или изменить размер пометки (V)", + "annotate.pen": "Перо", + "annotate.pen_hint": "Рисовать от руки (P)", + "annotate.arrow": "Стрелка", + "annotate.arrow_hint": "Нарисовать стрелку от хвоста к острию (A)", + "annotate.box": "Рамка", + "annotate.box_hint": "Нарисовать рамку (B)", + "annotate.highlight": "Маркер", + "annotate.highlight_hint": "Выделить полупрозрачным цветом (H)", + "annotate.text": "Текст", + "annotate.text_hint": "Щёлкните и введите подпись (T)", + "annotate.number": "Номер", + "annotate.number_hint": "Щёлкните, чтобы добавить следующий номер (N)", + "annotate.thin": "Тонкая", + "annotate.medium": "Средняя", + "annotate.thick": "Толстая", + "annotate.undo": "Отменить (Ctrl+Z)", + "annotate.redo": "Повторить (Ctrl+Shift+Z)", + "annotate.remove": "Удалить выбранную пометку (Del)", + "annotate.keys": "V выбор · P A B H T N инструмент · Del удаляет · Ctrl+Z отменяет · Esc закрывает", + "annotate.save": "Сохранить", + "annotate.save_next": "Сохранить и далее", "presets.editor_title": "Изменить пресет", "presets.delete_confirm": "Удалить «{0}»? Он также пропадёт из всех круговых меню и групп.", "presets.name_required": "Введите название", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 0e6cab38b..5a2274887 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -373,6 +373,7 @@ "preset.rotate_180": "หมุน 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "พลิกภาพ", + "preset.annotate": "ใส่คำอธิบาย", "preset.flip_horizontal": "พลิกแนวนอน", "preset.flip_horizontal.short": "แนวนอน", "preset.flip_vertical": "พลิกแนวตั้ง", @@ -900,6 +901,29 @@ "presets.conflict.Rename": "เติมตัวเลขต่อท้าย", "presets.conflict.Overwrite": "เขียนทับ", "presets.conflict.Skip": "ข้าม", + "annotate.select": "เลือก", + "annotate.select_hint": "เลือก ย้าย หรือปรับขนาดเครื่องหมาย (V)", + "annotate.pen": "ปากกา", + "annotate.pen_hint": "วาดอิสระ (P)", + "annotate.arrow": "ลูกศร", + "annotate.arrow_hint": "วาดลูกศรจากหางไปหัว (A)", + "annotate.box": "กรอบ", + "annotate.box_hint": "วาดกรอบ (B)", + "annotate.highlight": "ไฮไลต์", + "annotate.highlight_hint": "ทำเครื่องหมายด้วยสีโปร่ง (H)", + "annotate.text": "ข้อความ", + "annotate.text_hint": "คลิกแล้วพิมพ์ป้าย (T)", + "annotate.number": "หมายเลข", + "annotate.number_hint": "คลิกเพื่อเพิ่มหมายเลขถัดไป (N)", + "annotate.thin": "บาง", + "annotate.medium": "กลาง", + "annotate.thick": "หนา", + "annotate.undo": "เลิกทำ (Ctrl+Z)", + "annotate.redo": "ทำซ้ำ (Ctrl+Shift+Z)", + "annotate.remove": "ลบเครื่องหมายที่เลือก (Del)", + "annotate.keys": "V เลือก · P A B H T N เลือกเครื่องมือ · Del ลบ · Ctrl+Z เลิกทำ · Esc ปิด", + "annotate.save": "บันทึก", + "annotate.save_next": "บันทึกและถัดไป", "presets.editor_title": "แก้ไขพรีเซ็ต", "presets.delete_confirm": "ลบ “{0}” หรือไม่? พรีเซ็ตนี้จะถูกลบออกจากทุกแถบเครื่องมือและกลุ่มด้วย", "presets.name_required": "โปรดระบุชื่อ", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 2c286a28b..6748610c0 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -373,6 +373,7 @@ "preset.rotate_180": "180° döndür", "preset.rotate_180.short": "180°", "preset.group_flip": "Çevir", + "preset.annotate": "Not ekle", "preset.flip_horizontal": "Yatay çevir", "preset.flip_horizontal.short": "Yatay", "preset.flip_vertical": "Dikey çevir", @@ -900,6 +901,29 @@ "presets.conflict.Rename": "Numara ekle", "presets.conflict.Overwrite": "Üzerine yaz", "presets.conflict.Skip": "Atla", + "annotate.select": "Seç", + "annotate.select_hint": "İşareti seç, taşı veya boyutlandır (V)", + "annotate.pen": "Kalem", + "annotate.pen_hint": "Serbest çiz (P)", + "annotate.arrow": "Ok", + "annotate.arrow_hint": "Kuyruktan uca ok çiz (A)", + "annotate.box": "Kutu", + "annotate.box_hint": "Çerçeve çiz (B)", + "annotate.highlight": "Vurgu", + "annotate.highlight_hint": "Saydam renkle işaretle (H)", + "annotate.text": "Metin", + "annotate.text_hint": "Tıklayıp etiket yaz (T)", + "annotate.number": "Numara", + "annotate.number_hint": "Sonraki numarayı eklemek için tıkla (N)", + "annotate.thin": "İnce", + "annotate.medium": "Orta", + "annotate.thick": "Kalın", + "annotate.undo": "Geri al (Ctrl+Z)", + "annotate.redo": "Yinele (Ctrl+Shift+Z)", + "annotate.remove": "Seçili işareti kaldır (Del)", + "annotate.keys": "V seçer · P A B H T N araç seçer · Del kaldırır · Ctrl+Z geri alır · Esc kapatır", + "annotate.save": "Kaydet", + "annotate.save_next": "Kaydet ve sonraki", "presets.editor_title": "Ön ayarı düzenle", "presets.delete_confirm": "“{0}” silinsin mi? Tüm araç çubuklarından ve gruplardan da kaldırılır.", "presets.name_required": "Bir ad girin", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index c5c08c8e2..870575ea2 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -383,6 +383,7 @@ "preset.rotate_180": "旋转 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "翻转", + "preset.annotate": "批注", "preset.flip_horizontal": "水平翻转", "preset.flip_horizontal.short": "水平", "preset.flip_vertical": "垂直翻转", @@ -915,6 +916,29 @@ "presets.conflict.Rename": "添加编号", "presets.conflict.Overwrite": "覆盖", "presets.conflict.Skip": "跳过", + "annotate.select": "选择", + "annotate.select_hint": "选择、移动或调整标记大小 (V)", + "annotate.pen": "画笔", + "annotate.pen_hint": "自由绘制 (P)", + "annotate.arrow": "箭头", + "annotate.arrow_hint": "从尾到头画箭头 (A)", + "annotate.box": "方框", + "annotate.box_hint": "画一个框 (B)", + "annotate.highlight": "高亮", + "annotate.highlight_hint": "用半透明颜色标记 (H)", + "annotate.text": "文字", + "annotate.text_hint": "点击并输入标签 (T)", + "annotate.number": "编号", + "annotate.number_hint": "点击添加下一个编号 (N)", + "annotate.thin": "细", + "annotate.medium": "中", + "annotate.thick": "粗", + "annotate.undo": "撤销 (Ctrl+Z)", + "annotate.redo": "重做 (Ctrl+Shift+Z)", + "annotate.remove": "删除所选标记 (Del)", + "annotate.keys": "V 选择 · P A B H T N 选择工具 · Del 删除 · Ctrl+Z 撤销 · Esc 关闭", + "annotate.save": "保存", + "annotate.save_next": "保存并下一个", "presets.editor_title": "编辑预设", "presets.delete_confirm": "删除“{0}”?它也会从所有工具栏和组中移除。", "presets.name_required": "请输入名称", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index ae0747092..863cc4205 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -383,6 +383,7 @@ "preset.rotate_180": "旋轉 180°", "preset.rotate_180.short": "180°", "preset.group_flip": "翻轉", + "preset.annotate": "註解", "preset.flip_horizontal": "水平翻轉", "preset.flip_horizontal.short": "水平", "preset.flip_vertical": "垂直翻轉", @@ -915,6 +916,29 @@ "presets.conflict.Rename": "新增編號", "presets.conflict.Overwrite": "覆蓋", "presets.conflict.Skip": "跳過", + "annotate.select": "選取", + "annotate.select_hint": "選取、移動或調整標記大小 (V)", + "annotate.pen": "畫筆", + "annotate.pen_hint": "自由繪製 (P)", + "annotate.arrow": "箭頭", + "annotate.arrow_hint": "從尾到頭畫箭頭 (A)", + "annotate.box": "方框", + "annotate.box_hint": "畫一個框 (B)", + "annotate.highlight": "螢光筆", + "annotate.highlight_hint": "用半透明顏色標記 (H)", + "annotate.text": "文字", + "annotate.text_hint": "點一下並輸入標籤 (T)", + "annotate.number": "編號", + "annotate.number_hint": "點一下加入下一個編號 (N)", + "annotate.thin": "細", + "annotate.medium": "中", + "annotate.thick": "粗", + "annotate.undo": "復原 (Ctrl+Z)", + "annotate.redo": "重做 (Ctrl+Shift+Z)", + "annotate.remove": "移除所選標記 (Del)", + "annotate.keys": "V 選取 · P A B H T N 選擇工具 · Del 移除 · Ctrl+Z 復原 · Esc 關閉", + "annotate.save": "儲存", + "annotate.save_next": "儲存並下一個", "presets.editor_title": "編輯預設", "presets.delete_confirm": "刪除「{0}」?它也會從所有工具列和群組中移除。", "presets.name_required": "請輸入名稱", diff --git a/src/Filee.App/Controls/AnnotationCanvas.cs b/src/Filee.App/Controls/AnnotationCanvas.cs new file mode 100644 index 000000000..bec12200d --- /dev/null +++ b/src/Filee.App/Controls/AnnotationCanvas.cs @@ -0,0 +1,500 @@ +// The "Annotate" window's picture and its marks: the marks are painted by AnnotationPainter on Avalonia's own Skia +// canvas, so they look exactly as they will in the file; the selected one has its handles. A drag with a drawing tool +// draws a mark; with Select it moves a mark or, on a handle, resizes it. Text is typed in the window (TextRequested). + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using Filee.Core.Presets; +using Filee.Engines.Annotations; +using SkiaSharp; + +namespace Filee.App.Controls; + +/// What a press on the picture does. +public enum AnnotateMode +{ + Select, + Pen, + Arrow, + Box, + Highlight, + Text, + Number, +} + +public sealed class AnnotationCanvas : Control +{ + public static readonly StyledProperty SourceImageProperty = + AvaloniaProperty.Register(nameof(SourceImage)); + + public static readonly StyledProperty MarksProperty = + AvaloniaProperty.Register(nameof(Marks)); + + public static readonly StyledProperty ToolProperty = + AvaloniaProperty.Register(nameof(Tool)); + + /// The colour new marks get, "#RRGGBB". + public static readonly StyledProperty MarkColorProperty = + AvaloniaProperty.Register(nameof(MarkColor), AnnotateTool.Colors[0]); + + public static readonly StyledProperty MarkWidthProperty = + AvaloniaProperty.Register(nameof(MarkWidth), AnnotationWidth.Medium); + + public static readonly StyledProperty ImagePaddingProperty = + AvaloniaProperty.Register(nameof(ImagePadding)); + + public static readonly StyledProperty AccentBrushProperty = + AvaloniaProperty.Register(nameof(AccentBrush)); + + public Bitmap? SourceImage { get => GetValue(SourceImageProperty); set => SetValue(SourceImageProperty, value); } + public AnnotationSet? Marks { get => GetValue(MarksProperty); set => SetValue(MarksProperty, value); } + public AnnotateMode Tool { get => GetValue(ToolProperty); set => SetValue(ToolProperty, value); } + public string MarkColor { get => GetValue(MarkColorProperty); set => SetValue(MarkColorProperty, value); } + public AnnotationWidth MarkWidth { get => GetValue(MarkWidthProperty); set => SetValue(MarkWidthProperty, value); } + public Thickness ImagePadding { get => GetValue(ImagePaddingProperty); set => SetValue(ImagePaddingProperty, value); } + public IBrush? AccentBrush { get => GetValue(AccentBrushProperty); set => SetValue(AccentBrushProperty, value); } + + /// + /// Text is to be typed: a new one at a point (in parts of the picture, index -1), or text mark Index again. + /// The window shows the box to type in at Screen, the tag of an empty text there: the box follows the text + /// from that size, as the tag will. + /// + public event EventHandler? TextRequested; + + public readonly record struct TextRequest(int Index, AnnotationPoint At, Rect Screen, double FontSize); + + /// Handles are this many device-independent pixels across; a press this near a line hits it. + private const double HandleSize = 12, HitSlop = 7; + + private enum Drag { None, Draw, Move, Handle } + + private Drag _drag; + private int _handle; + private Point _start; + private List _startPoints = []; + + static AnnotationCanvas() + { + AffectsRender(SourceImageProperty, ImagePaddingProperty, AccentBrushProperty); + FocusableProperty.OverrideDefaultValue(false); + } + + // The picture is never mirrored, also in a right-to-left UI. + public AnnotationCanvas() + { + FlowDirection = FlowDirection.LeftToRight; + ClipToBounds = true; + } + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + base.OnPropertyChanged(change); + if (change.Property == MarksProperty) + { + if (change.GetOldValue() is { } old) + old.Changed -= InvalidateVisual; + if (change.GetNewValue() is { } marks) + marks.Changed += InvalidateVisual; + InvalidateVisual(); + } + else if (change.Property == ToolProperty) + { + Cursor = new Cursor(Tool == AnnotateMode.Select ? StandardCursorType.Arrow : StandardCursorType.Cross); + } + } + + /// 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); + return new Rect(area.X + (area.Width - size.Width * scale) / 2, area.Y + (area.Height - size.Height * scale) / 2, + size.Width * scale, size.Height * scale); + } + + // ───────── Drawing ───────── + + public override void Render(DrawingContext context) + { + base.Render(context); + var picture = CalculateImageBounds(); + if (SourceImage is null || picture.Width <= 0) + return; + context.DrawRectangle(Brushes.White, null, picture, 0, 0, new BoxShadows(new BoxShadow { Blur = 24, OffsetY = 6, Color = Color.FromArgb(0x33, 0, 0, 0) })); + using (context.PushRenderOptions(new RenderOptions { BitmapInterpolationMode = BitmapInterpolationMode.HighQuality })) + context.DrawImage(SourceImage, new Rect(SourceImage.Size), picture); + if (Marks is not { Count: > 0 } marks) + return; + context.Custom(new MarksDrawing(new Rect(Bounds.Size), picture, [.. marks.Marks.Where((_, i) => i != TypingIndex).Select(m => m.Clone())])); + if (ShowsSelection && marks.Selected != TypingIndex) + DrawSelection(context, marks.SelectedMark!, picture); + } + + /// + /// The text mark being typed again (-1 for none): not drawn, the window's box stands in for it as it will be. + /// + internal int TypingIndex + { + get => _typingIndex; + set + { + _typingIndex = value; + InvalidateVisual(); + } + } + + private int _typingIndex = -1; + + /// + /// Whether the selected mark shows its outline or handles. A mark being drawn is the selected one (the drag extends + /// it) but shows neither: they would only get in the way of the stroke. + /// + internal bool ShowsSelection => Marks?.SelectedMark is not null && _drag != Drag.Draw; + + /// The marks, painted by the file's own painter on Avalonia's Skia canvas. + private sealed class MarksDrawing(Rect bounds, Rect picture, IReadOnlyList marks) : ICustomDrawOperation + { + public Rect Bounds => bounds; + public bool HitTest(Point p) => false; + public bool Equals(ICustomDrawOperation? other) => false; + public void Dispose() { } + + public void Render(ImmediateDrawingContext context) + { + if (context.TryGetFeature(typeof(ISkiaSharpApiLeaseFeature)) is not ISkiaSharpApiLeaseFeature feature) + return; + using var lease = feature.Lease(); + var canvas = lease.SkCanvas; + canvas.Save(); + canvas.ClipRect(new SKRect((float)picture.Left, (float)picture.Top, (float)picture.Right, (float)picture.Bottom)); + canvas.Translate((float)picture.X, (float)picture.Y); + AnnotationPainter.Paint(canvas, marks, (float)picture.Width, (float)picture.Height); + canvas.Restore(); + } + } + + private void DrawSelection(DrawingContext context, Annotation mark, Rect picture) + { + var accent = AccentBrush ?? Brushes.MediumPurple; + var handles = HandlesOf(mark, picture); + if (handles.Count == 0) + { + // Pen, text and number: their outline, dashed, to show which is selected. + var box = BoundsOf(mark, picture).Inflate(4); + context.DrawRectangle(null, new Pen(accent, 1.5, new DashStyle([4, 3], 0)), box, 3, 3); + return; + } + foreach (var handle in handles) + { + context.DrawEllipse(Brushes.White, new Pen(accent, 2), handle, HandleSize / 2, HandleSize / 2); + } + } + + // ───────── Geometry ───────── + + private static Point ToScreen(AnnotationPoint point, Rect picture) => + new(picture.X + point.X * picture.Width, picture.Y + point.Y * picture.Height); + + private static AnnotationPoint ToPicture(Point screen, Rect picture) => new() + { + X = Math.Clamp((screen.X - picture.X) / picture.Width, 0, 1), + Y = Math.Clamp((screen.Y - picture.Y) / picture.Height, 0, 1), + }; + + /// + /// A mark's handles on screen: an arrow's two ends; a box's or highlight's four corners; none for the rest. + /// + internal static IReadOnlyList HandlesOf(Annotation mark, Rect picture) + { + if (mark.Points.Count < 2) + return []; + var a = ToScreen(mark.Points[0], picture); + var b = ToScreen(mark.Points[1], picture); + return mark.Kind switch + { + AnnotationKind.Arrow => [a, b], + AnnotationKind.Box or AnnotationKind.Highlight => [a, b, new Point(a.X, b.Y), new Point(b.X, a.Y)], + _ => [], + }; + } + + /// The screen rectangle a mark covers. + internal static Rect BoundsOf(Annotation mark, Rect picture) + { + float w = (float)picture.Width, h = (float)picture.Height; + SKRect rect; + switch (mark.Kind) + { + case AnnotationKind.Text: + rect = AnnotationPainter.TagOf(mark, w, h); + break; + case AnnotationKind.Number: + rect = AnnotationPainter.CircleOf(mark, w, h); + break; + default: + var xs = mark.Points.Select(p => p.X * w).ToList(); + var ys = mark.Points.Select(p => p.Y * h).ToList(); + rect = new SKRect((float)xs.Min(), (float)ys.Min(), (float)xs.Max(), (float)ys.Max()); + break; + } + return new Rect(picture.X + rect.Left, picture.Y + rect.Top, rect.Width, rect.Height); + } + + /// The mark on top under , -1 for none; the selected one first. + internal static int HitTest(IReadOnlyList marks, int selected, Point point, Rect picture) + { + if (selected >= 0 && selected < marks.Count && Hits(marks[selected], point, picture)) + return selected; + for (var i = marks.Count - 1; i >= 0; i--) + { + if (Hits(marks[i], point, picture)) + return i; + } + return -1; + } + + private static bool Hits(Annotation mark, Point point, Rect picture) + { + if (mark.Points.Count == 0) + return false; + var slop = HitSlop + AnnotationPainter.StrokeOf(mark.Width, (float)picture.Width, (float)picture.Height) / 2; + var screen = mark.Points.Select(p => ToScreen(p, picture)).ToList(); + switch (mark.Kind) + { + case AnnotationKind.Pen: + case AnnotationKind.Arrow: + for (var i = 1; i < screen.Count; i++) + { + if (Distance(point, screen[i - 1], screen[i]) <= slop) + return true; + } + return false; + case AnnotationKind.Box when screen.Count >= 2: + { + var rect = new Rect(screen[0], screen[1]).Normalize(); + return rect.Inflate(slop).Contains(point) && !rect.Deflate(slop).Contains(point); + } + case AnnotationKind.Highlight when screen.Count >= 2: + return new Rect(screen[0], screen[1]).Normalize().Contains(point); + default: + return BoundsOf(mark, picture).Inflate(2).Contains(point); + } + } + + private static double Distance(Point p, Point a, Point b) + { + var dx = b.X - a.X; + var dy = b.Y - a.Y; + var lengthSquared = dx * dx + dy * dy; + var t = lengthSquared == 0 ? 0 : Math.Clamp(((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lengthSquared, 0, 1); + var (x, y) = (a.X + t * dx, a.Y + t * dy); + return Math.Sqrt((p.X - x) * (p.X - x) + (p.Y - y) * (p.Y - y)); + } + + // ───────── Pointer ───────── + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + var picture = CalculateImageBounds(); + if (Marks is not { } marks || picture.Width <= 0 || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + return; + var point = e.GetPosition(this); + var at = ToPicture(point, picture); + _start = point; + e.Handled = true; + + switch (Tool) + { + case AnnotateMode.Select: + if (marks.SelectedMark is { } selected && HandleAt(selected, point, picture) is >= 0 and var handle) + { + _handle = handle; + Begin(Drag.Handle, e, selected); + return; + } + var hit = HitTest(marks.Marks, marks.Selected, point, picture); + marks.Select(hit); + if (hit < 0) + return; + if (e.ClickCount == 2 && marks.Marks[hit].Kind == AnnotationKind.Text) + { + TypeAgain(hit); + return; + } + Begin(Drag.Move, e, marks.Marks[hit]); + return; + case AnnotateMode.Text: + RequestText(-1, at, picture); + return; + case AnnotateMode.Number: + var number = NewMark(AnnotationKind.Number, at); + number.Number = marks.NextNumber; + marks.Add(number); + // Drawn, not picked: the next colour goes to the next mark, not to this one (Select picks a mark). + marks.Select(-1); + return; + default: + var kind = Tool switch + { + AnnotateMode.Pen => AnnotationKind.Pen, + AnnotateMode.Arrow => AnnotationKind.Arrow, + AnnotateMode.Box => AnnotationKind.Box, + _ => AnnotationKind.Highlight, + }; + marks.Begin(); + var mark = NewMark(kind, at); + mark.Points.Add(at.Clone()); + if (!marks.Add(mark)) + { + marks.Cancel(); + return; + } + _drag = Drag.Draw; + e.Pointer.Capture(this); + return; + } + } + + private Annotation NewMark(AnnotationKind kind, AnnotationPoint at) => + new() { Kind = kind, Color = MarkColor, Width = MarkWidth, Points = [at] }; + + private void Begin(Drag drag, PointerPressedEventArgs e, Annotation mark) + { + Marks!.Begin(); + _drag = drag; + _startPoints = [.. mark.Points.Select(p => p.Clone())]; + e.Pointer.Capture(this); + } + + /// The handle of under , -1 for none. + private static int HandleAt(Annotation mark, Point point, Rect picture) + { + var handles = HandlesOf(mark, picture); + for (var i = 0; i < handles.Count; i++) + { + if (Math.Abs(handles[i].X - point.X) <= HandleSize && Math.Abs(handles[i].Y - point.Y) <= HandleSize) + return i; + } + return -1; + } + + /// + /// Asks for text mark to be typed again (a double-click on it with Select). + /// + internal void TypeAgain(int index) => RequestText(index, Marks!.Marks[index].Points[0], CalculateImageBounds()); + + private void RequestText(int index, AnnotationPoint at, Rect picture) + { + var width = index >= 0 ? Marks!.Marks[index].Width : MarkWidth; + var empty = new Annotation { Kind = AnnotationKind.Text, Width = width, Points = [at], Text = " " }; + TextRequested?.Invoke(this, new TextRequest(index, at, BoundsOf(empty, picture), + AnnotationPainter.FontSizeOf(width, (float)picture.Width, (float)picture.Height))); + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (_drag == Drag.None || Marks is not { } marks || marks.Selected < 0) + return; + var picture = CalculateImageBounds(); + var point = e.GetPosition(this); + var at = ToPicture(point, picture); + var index = marks.Selected; + switch (_drag) + { + case Drag.Draw: + marks.Change(index, mark => + { + if (mark.Kind == AnnotationKind.Pen) + { + // A point each time the pointer has gone a little way: a smooth line, not thousands of points. + var last = ToScreen(mark.Points[^1], picture); + if (Math.Abs(point.X - last.X) + Math.Abs(point.Y - last.Y) >= 1.5) + mark.Points.Add(at); + } + else + { + mark.Points[1] = at; + } + }); + break; + case Drag.Move: + { + var dx = (point.X - _start.X) / picture.Width; + var dy = (point.Y - _start.Y) / picture.Height; + // The whole mark stays on the picture. + dx = Math.Clamp(dx, -_startPoints.Min(p => p.X), 1 - _startPoints.Max(p => p.X)); + dy = Math.Clamp(dy, -_startPoints.Min(p => p.Y), 1 - _startPoints.Max(p => p.Y)); + marks.Change(index, mark => + { + for (var i = 0; i < mark.Points.Count && i < _startPoints.Count; i++) + mark.Points[i] = new AnnotationPoint { X = _startPoints[i].X + dx, Y = _startPoints[i].Y + dy }; + }); + break; + } + case Drag.Handle: + marks.Change(index, mark => + { + switch (_handle) + { + case 0: + mark.Points[0] = at; + break; + case 1: + mark.Points[1] = at; + break; + case 2: // the corner left of the first point's column, on the second's row + mark.Points[0] = new AnnotationPoint { X = at.X, Y = mark.Points[0].Y }; + mark.Points[1] = new AnnotationPoint { X = mark.Points[1].X, Y = at.Y }; + break; + case 3: + mark.Points[1] = new AnnotationPoint { X = at.X, Y = mark.Points[1].Y }; + mark.Points[0] = new AnnotationPoint { X = mark.Points[0].X, Y = at.Y }; + break; + } + }); + break; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (_drag == Drag.None || Marks is not { } marks) + return; + var drew = _drag == Drag.Draw; + _drag = Drag.None; + e.Pointer.Capture(null); + // A click that drew nothing (a line of one point, a box with no size) leaves no mark. + if (drew && marks.SelectedMark is { } mark && !IsBigEnough(mark)) + marks.Cancel(); + else + marks.End(); + // Drawn, not picked: the next colour goes to the next mark, not to this one (Select picks a mark). + if (drew) + marks.Select(-1); + } + + private static bool IsBigEnough(Annotation mark) + { + if (mark.Points.Count < 2) + return false; + var width = mark.Points.Max(p => p.X) - mark.Points.Min(p => p.X); + var height = mark.Points.Max(p => p.Y) - mark.Points.Min(p => p.Y); + return mark.Kind == AnnotationKind.Pen || mark.Kind == AnnotationKind.Arrow + ? Math.Max(width, height) >= AnnotateTool.MinSize + : width >= AnnotateTool.MinSize && height >= AnnotateTool.MinSize; + } +} diff --git a/src/Filee.App/Controls/AnnotationSet.cs b/src/Filee.App/Controls/AnnotationSet.cs new file mode 100644 index 000000000..799ae2e21 --- /dev/null +++ b/src/Filee.App/Controls/AnnotationSet.cs @@ -0,0 +1,166 @@ +// The marks the "Annotate" window draws on a picture, without a window: the marks in the order drawn (the last on top), +// the one selected, and undo / redo of adding, moving, resizing, restyling, retyping and removing them. A drag is one +// undo step however far it goes. + +using Filee.Core.Presets; + +namespace Filee.App.Controls; + +public sealed class AnnotationSet +{ + /// Most marks one picture takes. + public const int MaxMarks = 500; + + /// Undo steps kept. + private const int MaxUndo = 100; + + private readonly List _marks = []; + private readonly List _undo = []; + private readonly List _redo = []; + private Snapshot? _gesture; + private bool _gestureChanged; + + private sealed record Snapshot(Annotation[] Marks, int Selected); + + /// The marks, in the order drawn. + public IReadOnlyList Marks => _marks; + + /// The selected mark, -1 for none. + public int Selected { get; private set; } = -1; + + public Annotation? SelectedMark => Selected >= 0 ? _marks[Selected] : null; + public int Count => _marks.Count; + public bool CanUndo => _undo.Count > 0; + public bool CanRedo => _redo.Count > 0; + + /// The next number a Number mark gets: one more than the highest on the picture. + public int NextNumber => _marks.Where(m => m.Kind == AnnotationKind.Number).Select(m => m.Number).DefaultIfEmpty(0).Max() + 1; + + /// Something changed: a mark, the selection or what can be undone. + public event Action? Changed; + + /// A drag starts: what it does until is one undo step. + public void Begin() + { + _gesture ??= Take(); + _gestureChanged = false; + } + + /// The drag ends: one undo step when it changed anything. + public void End() + { + if (_gesture is not { } before) + return; + _gesture = null; + if (_gestureChanged) + Push(before); + Changed?.Invoke(); + } + + /// The drag is dropped: everything as it was before it (a click that drew nothing). + public void Cancel() + { + if (_gesture is not { } before) + return; + _gesture = null; + Restore(before); + Changed?.Invoke(); + } + + /// Adds a mark and selects it; false when there are already. + public bool Add(Annotation mark) + { + if (_marks.Count >= MaxMarks) + return false; + Record(); + _marks.Add(mark); + Selected = _marks.Count - 1; + Changed?.Invoke(); + return true; + } + + /// Changes mark (a drag's step, or one undo step on its own). + public void Change(int index, Action change) + { + if (index < 0 || index >= _marks.Count) + return; + Record(); + change(_marks[index]); + Changed?.Invoke(); + } + + public void Select(int index) + { + index = index >= 0 && index < _marks.Count ? index : -1; + if (index == Selected) + return; + Selected = index; + Changed?.Invoke(); + } + + /// Removes the selected mark; none is selected afterwards. False when none was. + public bool RemoveSelected() + { + if (Selected < 0) + return false; + Record(); + _marks.RemoveAt(Selected); + Selected = -1; + Changed?.Invoke(); + return true; + } + + /// Starts over with no marks and nothing to undo (a new picture). + public void Reset() + { + _gesture = null; + _marks.Clear(); + _undo.Clear(); + _redo.Clear(); + Selected = -1; + Changed?.Invoke(); + } + + public void Undo() => Step(_undo, _redo); + + public void Redo() => Step(_redo, _undo); + + 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()); + Restore(snapshot); + Changed?.Invoke(); + } + + /// An undo step for a change made now, unless a drag records one for itself. + private void Record() + { + if (_gesture is not null) + { + _gestureChanged = true; + return; + } + Push(Take()); + } + + private void Push(Snapshot snapshot) + { + _undo.Add(snapshot); + if (_undo.Count > MaxUndo) + _undo.RemoveAt(0); + _redo.Clear(); + } + + private Snapshot Take() => new([.. _marks.Select(m => m.Clone())], Selected); + + private void Restore(Snapshot snapshot) + { + _marks.Clear(); + _marks.AddRange(snapshot.Marks.Select(m => m.Clone())); + Selected = Math.Min(snapshot.Selected, _marks.Count - 1); + } +} diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs index 05dc4ecc8..ca558044c 100644 --- a/src/Filee.App/Services/PresetAvailability.cs +++ b/src/Filee.App/Services/PresetAvailability.cs @@ -133,6 +133,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.IsAnnotateTool) + return formats.Any(f => HideService.CanOpen(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) diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs index 955b0851d..99ddcbbb6 100644 --- a/src/Filee.App/Services/WindowService.cs +++ b/src/Filee.App/Services/WindowService.cs @@ -37,10 +37,12 @@ public sealed class WindowService(ILocalizer loc, IServiceProvider services, Mem private CropOverlayWindow? _cropOverlay; private ClipWindow? _clipWindow; private HideWindow? _hideWindow; + private AnnotateWindow? _annotateWindow; private ImageEditWindow? _imageEdit; internal ClipWindow? ClipWindow => _clipWindow; internal HideWindow? HideWindow => _hideWindow; + internal AnnotateWindow? AnnotateWindow => _annotateWindow; internal ImageEditWindow? ImageEdit => _imageEdit; private readonly HashSet _spreadsheetStudios = []; @@ -152,6 +154,8 @@ public void ShowTool(IReadOnlyList files, Preset preset, PixelPoint? nea ShowClipWindow(files, preset, near); else if (preset.IsHideTool) ShowHideWindow(files, preset, near); + else if (preset.IsAnnotateTool) + ShowAnnotateWindow(files, preset); 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 @@ -262,6 +266,34 @@ public void ShowHideWindow(IReadOnlyList files, Preset preset, PixelPoin win.Activate(); } + /// + /// Opens the "Annotate" window on the pictures among the files. Each picture is written by an ordinary conversion, + /// shown in the toast. + /// + public void ShowAnnotateWindow(IReadOnlyList files, Preset preset) + { + var pictures = files.Where(f => FormatRegistry.Detect(f) is { } format && HideService.CanOpen(format.Id)).ToList(); + if (pictures.Count == 0) + return; + _annotateWindow?.Close(); + var conversions = services.GetRequiredService(); + var planner = services.GetRequiredService().CreatePlanner(); + var vm = new AnnotateViewModel(pictures, preset, loc, services.GetRequiredService(), + path => AnnotateTool.TargetFor(FormatRegistry.Detect(path)?.Id ?? "", planner), + (path, conversion) => conversions.Start([path], conversion), services.GetService>()); + var win = new AnnotateWindow { DataContext = vm }; + PrepareWindow(win); + win.Closed += (_, _) => + { + if (_annotateWindow == win) + _annotateWindow = null; + trimmer.Request(); + }; + _annotateWindow = 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/ViewModels/AnnotateViewModel.cs b/src/Filee.App/ViewModels/AnnotateViewModel.cs new file mode 100644 index 000000000..78964010d --- /dev/null +++ b/src/Filee.App/ViewModels/AnnotateViewModel.cs @@ -0,0 +1,302 @@ +// State of the "Annotate" window: one picture at a time with the marks drawn on it (AnnotationSet), the tool, colour +// and width in use (a colour or width picked while a mark is selected with Select restyles it; a mark just drawn isn't +// selected, so a colour picked next goes to the next mark), and the conversion that writes the picture with its marks, +// which runs in the toast like any other. The colour and width are kept in the tool. + +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 Microsoft.Extensions.Logging; + +namespace Filee.App.ViewModels; + +public sealed partial class AnnotateViewModel : ObservableObject, IDisposable +{ + private readonly IReadOnlyList _files; + private readonly Preset _preset; + private readonly ILocalizer _loc; + private readonly UserDataStore _store; + private readonly Func _targetFor; + private readonly Action _export; + private readonly ILogger? _log; + private bool _closed; + + public AnnotateViewModel(IReadOnlyList files, Preset tool, ILocalizer loc, UserDataStore store, + Func targetFor, Action export, ILogger? log = null) + { + _files = files; + _preset = tool; + _loc = loc; + _store = store; + _targetFor = targetFor; + _export = export; + _log = log; + _color = AnnotateTool.Colors.Contains(tool.Image.AnnotationColor) ? tool.Image.AnnotationColor! : AnnotateTool.Colors[0]; + _width = tool.Image.AnnotationWidth; + _hasMultipleFiles = files.Count > 1; + Swatches = [.. AnnotateTool.Colors.Select(c => new AnnotateSwatch(c, this))]; + Marks.Changed += OnMarksChanged; + UpdateSwatches(); + _ = LoadFileAsync(0); + } + + /// The marks on the picture shown. + public AnnotationSet Marks { get; } = new(); + + public IReadOnlyList Swatches { get; } + + [ObservableProperty] private int _currentIndex; + [ObservableProperty] private string _currentFileName = ""; + [ObservableProperty] private string _counterText = ""; + [ObservableProperty] private bool _hasMultipleFiles; + [ObservableProperty] private Bitmap? _previewImage; + [ObservableProperty] private string _statusNotice = ""; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SaveCommand))] + private bool _isBusy; + + /// The tool in use (what a press on the picture does). + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsSelect), nameof(IsPen), nameof(IsArrow), nameof(IsBox), nameof(IsHighlight), nameof(IsText), nameof(IsNumber))] + private AnnotateMode _mode = AnnotateMode.Arrow; + + [ObservableProperty] private string _color; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsThin), nameof(IsMedium), nameof(IsThick))] + private AnnotationWidth _width; + + public bool IsSelect => Mode == AnnotateMode.Select; + public bool IsPen => Mode == AnnotateMode.Pen; + public bool IsArrow => Mode == AnnotateMode.Arrow; + public bool IsBox => Mode == AnnotateMode.Box; + public bool IsHighlight => Mode == AnnotateMode.Highlight; + public bool IsText => Mode == AnnotateMode.Text; + public bool IsNumber => Mode == AnnotateMode.Number; + public bool IsThin => Width == AnnotationWidth.Thin; + public bool IsMedium => Width == AnnotationWidth.Medium; + public bool IsThick => Width == AnnotationWidth.Thick; + + public bool CanUndo => Marks.CanUndo; + public bool CanRedo => Marks.CanRedo; + public bool HasSelection => Marks.Selected >= 0; + + public string SaveText => _loc[CurrentIndex < _files.Count - 1 ? "annotate.save_next" : "annotate.save"]; + + public event Action? CloseRequested; + + partial void OnModeChanged(AnnotateMode value) + { + // A drawing tool leaves the selection: what it draws gets selected instead. + if (value != AnnotateMode.Select) + Marks.Select(-1); + } + + private void OnMarksChanged() + { + OnPropertyChanged(nameof(CanUndo)); + OnPropertyChanged(nameof(CanRedo)); + OnPropertyChanged(nameof(HasSelection)); + // A selected mark's colour and width show in the bar. + if (Marks.SelectedMark is { } mark && (mark.Color != Color || mark.Width != Width)) + { + Color = mark.Color; + OnPropertyChanged(nameof(Color)); + Width = mark.Width; + OnPropertyChanged(nameof(Width)); + OnPropertyChanged(nameof(IsThin)); + OnPropertyChanged(nameof(IsMedium)); + OnPropertyChanged(nameof(IsThick)); + UpdateSwatches(); + } + } + + [RelayCommand] + private void SetMode(AnnotateMode mode) => Mode = mode; + + /// The colour for new marks, and the selected mark's. + [RelayCommand] + internal void SetColor(string color) + { + Color = color; + if (Marks.Selected >= 0 && Marks.SelectedMark!.Color != color) + Marks.Change(Marks.Selected, mark => mark.Color = color); + UpdateSwatches(); + } + + /// The width for new marks, and the selected mark's. + [RelayCommand] + private void SetWidth(AnnotationWidth width) + { + Width = width; + if (Marks.Selected >= 0 && Marks.SelectedMark!.Width != width) + Marks.Change(Marks.Selected, mark => mark.Width = width); + } + + private void UpdateSwatches() + { + foreach (var swatch in Swatches) + swatch.IsSelected = swatch.Color == Color; + } + + /// A new text typed at ; nothing for blank text. + public void AddText(AnnotationPoint at, string text) + { + if (string.IsNullOrWhiteSpace(text)) + return; + Marks.Add(new Annotation { Kind = AnnotationKind.Text, Color = Color, Width = Width, Points = [at], Text = text.Trim() }); + // Typed with the Text tool, not picked: the next colour goes to the next mark. + if (Mode != AnnotateMode.Select) + Marks.Select(-1); + } + + /// Text mark typed again; blank text removes it. + public void EditText(int index, string text) + { + if (index < 0 || index >= Marks.Count) + return; + Marks.Select(index); + if (string.IsNullOrWhiteSpace(text)) + Marks.RemoveSelected(); + else if (Marks.Marks[index].Text != text.Trim()) + Marks.Change(index, mark => mark.Text = text.Trim()); + } + + [RelayCommand] + private void Undo() => Marks.Undo(); + + [RelayCommand] + private void Redo() => Marks.Redo(); + + [RelayCommand] + private void RemoveSelected() => Marks.RemoveSelected(); + + // ───────── Pictures and saving ───────── + + /// Opens picture with no marks; past the last one, closes. + public async Task LoadFileAsync(int index) + { + if (_closed) + return; + if (index >= _files.Count) + { + CloseRequested?.Invoke(); + return; + } + IsBusy = true; + CurrentIndex = index; + var path = _files[index]; + CurrentFileName = Path.GetFileName(path); + CounterText = _loc.Format("crop.page_counter", index + 1, _files.Count); + OnPropertyChanged(nameof(SaveText)); + StatusNotice = ""; + Marks.Reset(); + try + { + var (preview, _, _) = await HideService.LoadPreviewAsync(path, _preset); + Bitmap bitmap; + using (preview) + bitmap = await Task.Run(() => HideService.ToBitmap(preview)); + if (_closed) + { + bitmap.Dispose(); + return; + } + var old = PreviewImage; + PreviewImage = bitmap; + old?.Dispose(); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogInformation(ex, "Could not open {File} to annotate", CurrentFileName); + StatusNotice = $"{CurrentFileName}: {_loc[ex is NotSupportedException ? "error.unsupported_source" : "error.conversion_failed"]}"; + IsBusy = false; + await LoadFileAsync(index + 1); + return; + } + IsBusy = false; + } + + private bool CanSave() => !IsBusy && PreviewImage is not null; + + /// + /// Starts the conversion that writes the picture with its marks (shown in the toast), keeps the colour and width, + /// opens the next picture or closes. A picture without marks is saved as it is drawn: a copy. + /// + [RelayCommand(CanExecute = nameof(CanSave))] + private async Task SaveAsync() + { + var path = _files[CurrentIndex]; + Remember(); + if (Marks.Marks.Any(AnnotateTool.IsDrawable)) + { + try + { + _export(path, AnnotateTool.ConversionOf(_preset, _targetFor(path), Marks.Marks)); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + _log?.LogError(ex, "Could not start annotating {File}", CurrentFileName); + StatusNotice = $"{CurrentFileName}: {_loc["error.conversion_failed"]}"; + } + } + await LoadFileAsync(CurrentIndex + 1); + } + + [RelayCommand] + private async Task SkipAsync() + { + if (!IsBusy) + await LoadFileAsync(CurrentIndex + 1); + } + + [RelayCommand] + private void Close() + { + Remember(); + CloseRequested?.Invoke(); + } + + private void Remember() + { + AnnotateTool.Remember(_preset, Color, Width); + if (_store.FindPreset(_preset.Id) is not { IsAnnotateTool: true } saved + || (saved.Image.AnnotationColor == Color && saved.Image.AnnotationWidth == Width)) + return; + AnnotateTool.Remember(saved, Color, Width); + try + { + _store.SaveLibrary(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + _log?.LogWarning(ex, "Could not remember the annotation style"); + } + } + + public void Dispose() + { + _closed = true; + Marks.Changed -= OnMarksChanged; + PreviewImage?.Dispose(); + PreviewImage = null; + } +} + +/// A colour in the bar. +public sealed partial class AnnotateSwatch(string color, AnnotateViewModel owner) : ObservableObject +{ + public string Color { get; } = color; + public Avalonia.Media.IBrush Brush { get; } = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse(color)); + + [ObservableProperty] private bool _isSelected; + + [RelayCommand] + private void Choose() => owner.SetColor(Color); +} diff --git a/src/Filee.App/Views/AnnotateWindow.axaml b/src/Filee.App/Views/AnnotateWindow.axaml new file mode 100644 index 000000000..26b897bac --- /dev/null +++ b/src/Filee.App/Views/AnnotateWindow.axaml @@ -0,0 +1,202 @@ + + + + M10.07,14.27C10.57,14.03 11.16,14.25 11.4,14.75L13.7,19.74L15.5,18.89L13.19,13.91C12.95,13.41 13.17,12.81 13.67,12.58L13.95,12.5L16.25,12.05L8,5.12V15.9L9.82,14.43L10.07,14.27M13.64,21.97C13.14,22.21 12.54,22 12.31,21.5L10.13,16.76L7.62,18.78C7.45,18.92 7.24,19 7,19A1,1 0 0,1 6,18V3A1,1 0 0,1 7,2C7.24,2 7.47,2.09 7.64,2.23L7.65,2.22L19.14,11.86C19.57,12.22 19.62,12.85 19.27,13.27C19.12,13.45 18.91,13.57 18.7,13.61L15.54,14.23L17.74,18.96C18,19.46 17.76,20.05 17.26,20.28L13.64,21.97Z + M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z + M5,17.59L15.59,7H9V5H19V15H17V8.41L6.41,19L5,17.59Z + M4,6V18H20V6H4M18,16H6V8H18V16Z + M18.5,1.15C17.97,1.15 17.46,1.34 17.07,1.73L11.26,7.55L16.91,13.2L22.73,7.39C23.5,6.61 23.5,5.35 22.73,4.56L19.89,1.73C19.5,1.34 19,1.15 18.5,1.15M10.3,8.5L4.34,14.46C3.56,15.24 3.56,16.5 4.36,17.31C3.14,18.54 1.9,19.77 0.67,21H6.33L7.19,20.14C7.97,20.9 9.22,20.89 10,20.12L15.95,14.16 + M18.5,4L19.66,8.35L18.7,8.61C18.25,7.74 17.79,6.87 17.26,6.43C16.73,6 16.11,6 15.5,6H13V16.5C13,17 13,17.5 13.33,17.75C13.67,18 14.33,18 15,18V19H9V18C9.67,18 10.33,18 10.67,17.75C11,17.5 11,17 11,16.5V6H8.5C7.89,6 7.27,6 6.74,6.43C6.21,6.87 5.75,7.74 5.3,8.61L4.34,8.35L5.5,4H18.5Z + M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2M10,7V9H12V17H14V7H10Z + 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - + - - - + public sealed class Annotation { - [JsonConverter(typeof(LenientEnumJsonConverter))] + [JsonConverter(typeof(AnnotationKindJsonConverter))] public AnnotationKind Kind { get; set; } /// "#RRGGBB". @@ -502,7 +513,14 @@ public sealed class Annotation /// /// The pen's line; the two corners of a box or highlight; an arrow's tail and tip; a text's or number's place. /// - public List Points { get; set; } = []; + public List Points + { + get => _points; + // Read leniently: no list, or empty places in it, are no points. + set => _points = value is null ? [] : [.. value.Where(point => point is not null)]; + } + + private List _points = []; /// What a says. public string? Text { get; set; } diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 5df12af9b..6ab641431 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -961,10 +961,12 @@ public static bool ApplyToLibrary(int from, List presets, List p.Id != BuiltInData.AnnotateId)) { - presets.AddRange(BuiltInData.AnnotatePresets()); + var hide = presets.FindIndex(p => p.Id == BuiltInData.HidePresets().Single().Id); + presets.InsertRange(hide < 0 ? presets.Count : hide + 1, BuiltInData.AnnotatePresets()); changed = true; } return changed; diff --git a/src/Filee.Engines/Annotations/AnnotationPainter.cs b/src/Filee.Engines/Annotations/AnnotationPainter.cs index 4722db2a0..143d73ab8 100644 --- a/src/Filee.Engines/Annotations/AnnotationPainter.cs +++ b/src/Filee.Engines/Annotations/AnnotationPainter.cs @@ -2,7 +2,7 @@ // Annotator draws the file with it, so what one sees is what one gets. Points are parts of the picture; every size is a // part of the picture's shorter side, so marks look the same on a small preview and the full-size file. -using System.Collections.Concurrent; +using System.Globalization; using Filee.Core.Presets; using SkiaSharp; @@ -103,9 +103,8 @@ public static SKRect RectOf(Annotation mark, float width, float height) /// public static SKRect TagOf(Annotation mark, float width, float height) { - using var font = FontFor(mark.Text ?? "", FontSizeOf(mark.Width, width, height)); - var size = font.Size; - var textWidth = font.MeasureText(string.IsNullOrEmpty(mark.Text) ? " " : mark.Text); + var size = FontSizeOf(mark.Width, width, height); + var textWidth = LabelText.Lay(string.IsNullOrEmpty(mark.Text) ? " " : mark.Text, size, null, 0, 0); var at = At(mark.Points[0], width, height); return new SKRect(at.X, at.Y, at.X + textWidth + size * 0.9f, at.Y + size * 1.5f); } @@ -118,6 +117,36 @@ public static SKRect CircleOf(Annotation mark, float width, float height) return new SKRect(at.X - radius, at.Y - radius, at.X + radius, at.Y + radius); } + /// + /// Every pixel a mark can touch, an arrow's head and the smoothed edge included; empty for one that draws nothing. + /// + public static SKRect ExtentOf(Annotation mark, float width, float height) + { + if (mark.Points.Count == 0 || mark.Kind == AnnotationKind.Unknown || (mark.Kind == AnnotationKind.Text && string.IsNullOrEmpty(mark.Text))) + return SKRect.Empty; + SKRect rect; + var margin = 2f; + switch (mark.Kind) + { + case AnnotationKind.Text: + rect = TagOf(mark, width, height); + // Accents stacked on a letter, and deep descenders, can reach well past the tag. + margin += rect.Height; + break; + case AnnotationKind.Number: + rect = CircleOf(mark, width, height); + break; + default: + var points = mark.Points.Select(p => At(p, width, height)).ToList(); + rect = new SKRect(points.Min(p => p.X), points.Min(p => p.Y), points.Max(p => p.X), points.Max(p => p.Y)); + // An arrow's head reaches out about 2.6 lines from its middle; a line, half of one. + margin += StrokeOf(mark.Width, width, height) * 3; + break; + } + rect.Inflate(margin, margin); + return rect; + } + private static void DrawArrow(SKCanvas canvas, SKPoint tail, SKPoint tip, SKColor color, float stroke) { var dx = tip.X - tail.X; @@ -147,11 +176,7 @@ private static void DrawTag(SKCanvas canvas, Annotation mark, float width, float var size = FontSizeOf(mark.Width, width, height); using (var fill = new SKPaint { Color = color, IsAntialias = true, Style = SKPaintStyle.Fill }) canvas.DrawRoundRect(tag, size * 0.3f, size * 0.3f, fill); - using var font = FontFor(mark.Text!, size); - using var ink = new SKPaint { Color = InkOn(color), IsAntialias = true }; - font.GetFontMetrics(out var metrics); - var baseline = tag.MidY - (metrics.Ascent + metrics.Descent) / 2; - canvas.DrawText(mark.Text!, tag.Left + size * 0.45f, baseline, SKTextAlign.Left, font, ink); + DrawText(canvas, mark.Text!, size, tag.Left + size * 0.45f, tag.MidY, centred: false, InkOn(color)); } private static void DrawNumber(SKCanvas canvas, Annotation mark, float width, float height, SKColor color) @@ -162,13 +187,46 @@ private static void DrawNumber(SKCanvas canvas, Annotation mark, float width, fl canvas.DrawCircle(circle.MidX, circle.MidY, radius, ring); using (var fill = new SKPaint { Color = color, IsAntialias = true, Style = SKPaintStyle.Fill }) canvas.DrawCircle(circle.MidX, circle.MidY, radius * 0.86f, fill); - var text = mark.Number.ToString(System.Globalization.CultureInfo.InvariantCulture); - using var font = FontFor(text, radius * (text.Length > 1 ? 0.95f : 1.15f)); - using var ink = new SKPaint { Color = InkOn(color), IsAntialias = true }; - font.GetFontMetrics(out var metrics); - canvas.DrawText(text, circle.MidX, circle.MidY - (metrics.Ascent + metrics.Descent) / 2, SKTextAlign.Center, font, ink); + var text = mark.Number.ToString(CultureInfo.InvariantCulture); + var size = radius * (text.Length > 1 ? 0.95f : 1.15f); + // Three digits and more are made smaller to stay inside the coloured disc. + var room = radius * 0.86f * 1.56f; + var wide = LabelText.Lay(text, size, null, 0, 0); + if (wide > room) + size *= room / wide; + DrawText(canvas, text, size, circle.MidX, circle.MidY, centred: true, InkOn(color)); + } + + /// + /// Draws from (or centred on it), its letters' height centred on + /// . Shaped as typed: Arabic joined and right to left, Indic and Thai marks in place. + /// + private static void DrawText(SKCanvas canvas, string text, float size, float x, float middle, bool centred, SKColor ink) + { + middle = LabelText.BaselineFor(text, size, middle); + if (centred) + x -= LabelText.Lay(text, size, null, 0, 0) / 2; + using var builder = new SKTextBlobBuilder(); + LabelText.Lay(text, size, builder, x, middle); + using var blob = builder.Build(); + if (blob is null) + return; + using var paint = new SKPaint { Color = ink, IsAntialias = true, Style = SKPaintStyle.Fill }; + canvas.DrawText(blob, 0, 0, paint); } + /// + /// The fonts labels are drawn in: the UI's font chain in bold, as the box they are typed in uses it (the app passes + /// it at start and when the language changes). + /// + public static void UseFonts(Func> fonts) => LabelText.UseFonts(fonts); + + /// Reads the fonts labels will need ahead (off the UI thread), so the first label doesn't wait. + public static void WarmFonts() => LabelText.Warm(); + + /// Whether a label reads right to left as a whole, as its text box is typed. + internal static bool IsRightToLeft(string text) => LabelText.IsRightToLeft(text); + private static SKPaint Line(SKColor color, float stroke) => new() { Color = color, @@ -179,44 +237,6 @@ private static void DrawNumber(SKCanvas canvas, Annotation mark, float width, fl StrokeJoin = SKStrokeJoin.Round, }; - /// - /// A bold font that has the text's letters: the system's own, else one the system finds for them (Korean, Thai …). - /// - private static SKFont FontFor(string text, float size) - { - var typeface = SKTypeface.FromFamilyName(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright) - ?? SKTypeface.Default; - var missing = text.EnumerateRunes().FirstOrDefault(r => !char.IsWhiteSpace((char)Math.Min(r.Value, char.MaxValue)) - && typeface.GetGlyph(r.Value) == 0); - if (missing.Value != 0 && FallbackFor(missing.Value) is { } fallback) - typeface = fallback; - return new SKFont(typeface, size) { Edging = SKFontEdging.Antialias, Subpixel = true }; - } - - private static readonly ConcurrentDictionary Fallbacks = new(); - - /// - /// A bold font for a letter the system's own lacks, or null when no installed font has it. Skia's Linux build here - /// finds fonts by family name but never by letter (MatchCharacter returns null even with a Korean font installed), - /// so the installed families are looked through for one that has it, sans-serif ones first. - /// - private static SKTypeface? FallbackFor(int codePoint) => Fallbacks.GetOrAdd(codePoint, static letter => - { - var fonts = SKFontManager.Default; - if (fonts.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, letter) - is { } matched) - return matched; - foreach (var family in fonts.FontFamilies.OrderBy(f => f.Contains("Serif", StringComparison.OrdinalIgnoreCase) - || f.Contains("Mono", StringComparison.OrdinalIgnoreCase))) - { - var face = fonts.MatchFamily(family, SKFontStyle.Bold); - if (face?.ContainsGlyph(letter) == true) - return face; - face?.Dispose(); - } - return null; - }); - /// White on a dark colour, near-black on a light one (yellow), so text and numbers read. internal static SKColor InkOn(SKColor color) => 0.2126 * color.Red + 0.7152 * color.Green + 0.0722 * color.Blue > 170 ? new SKColor(0x1C, 0x1C, 0x1C) : SKColors.White; diff --git a/src/Filee.Engines/Annotations/Annotator.cs b/src/Filee.Engines/Annotations/Annotator.cs index c06c699a3..19c82d238 100644 --- a/src/Filee.Engines/Annotations/Annotator.cs +++ b/src/Filee.Engines/Annotations/Annotator.cs @@ -1,7 +1,9 @@ -// "Annotate" in the file: the marks painted (AnnotationPainter, as the window draws them) on a transparent layer the -// size of the picture, then laid over it. The picture as it is seen: before it is turned or resized. +// "Annotate" in the file: the marks painted (AnnotationPainter, as the window draws them) on transparent layers that +// are laid over the picture, only over the part the marks cover and a band at a time, so a huge picture needs no second +// copy of itself. The picture as it is seen: before it is turned or resized. using Filee.Core.Presets; +using Filee.Engines.Magick; using ImageMagick; using SkiaSharp; @@ -9,6 +11,11 @@ namespace Filee.Engines.Annotations; public static class Annotator { + /// + /// A band of the layer holds at most this many pixels (16 MB as RGBA), however large the picture. + /// + private const int BandPixels = 4 << 20; + /// /// Draws on (upright); does nothing without marks. /// @@ -17,14 +24,86 @@ public static void Apply(IMagickImage image, ImageOptions options) if (!options.HasAnnotations) return; int width = (int)image.Width, height = (int)image.Height; - using var bitmap = new SKBitmap(new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul)); + var marks = options.Annotations!; + if (Covered(marks, width, height) is not { } area) + return; + + // The marks are sRGB. A greyscale picture can't show them, so it becomes a colour one (through its own profile + // when it has one). + var profile = image.GetColorProfile(); + var space = image.ColorSpace; + if (space is ColorSpace.Gray or ColorSpace.LinearGray) + { + if (profile?.ColorSpace is ColorSpace.Gray or ColorSpace.LinearGray) + { + image.TransformColorSpace(profile, ColorProfiles.SRGB); + profile = null; + } + image.ColorSpace = ColorSpace.sRGB; + image.ColorType = image.HasAlpha ? ColorType.TrueColorAlpha : ColorType.TrueColor; + space = ColorSpace.sRGB; + // An RGB profile stays, as the picture is now read through it. + if (profile is not null && profile.ColorSpace == ColorSpace.sRGB && image.GetColorProfile() is null) + image.SetProfile(profile); + } + // Each layer is converted to the picture's own colours: through its profile when it has one of its kind that + // isn't sRGB (a phone's Display P3, a print CMYK), else by formula (CMYK without one). Copied channel by + // channel, red came out teal on CMYK, and too vivid where a viewer applies a wide-gamut profile. + var through = profile is not null && profile.ColorSpace == space + && profile.Description?.Contains("sRGB", StringComparison.OrdinalIgnoreCase) != true ? profile : null; + var bandHeight = Math.Max(1, BandPixels / area.Width); + for (var top = area.Top; top < area.Bottom; top += bandHeight) + { + using var band = Paint(marks, width, height, new SKRectI(area.Left, top, area.Right, Math.Min(area.Bottom, top + bandHeight))); + if (through is not null) + { + band.RenderingIntent = RenderingIntent.Relative; + band.TransformColorSpace(ColorProfiles.SRGB, through); + } + else if (space != ColorSpace.sRGB) + { + band.ColorSpace = space; + } + image.Composite(band, area.Left, top, CompositeOperator.Over); + } + // The small previews a file carries (EXIF's thumbnail) still show the picture without its marks. + AreaHider.DropPreviews(image); + } + + /// + /// The part of the picture the marks can touch, in whole pixels, or null when they touch none of it. + /// + private static SKRectI? Covered(IEnumerable marks, int width, int height) + { + var (left, top, right, bottom) = (double.MaxValue, double.MaxValue, double.MinValue, double.MinValue); + foreach (var mark in marks) + { + var extent = AnnotationPainter.ExtentOf(mark, width, height); + if (extent.IsEmpty) + continue; + (left, top) = (Math.Min(left, extent.Left), Math.Min(top, extent.Top)); + (right, bottom) = (Math.Max(right, extent.Right), Math.Max(bottom, extent.Bottom)); + } + var area = new SKRectI(Math.Max(0, (int)Math.Floor(left)), Math.Max(0, (int)Math.Floor(top)), + Math.Min(width, (int)Math.Ceiling(right)), Math.Min(height, (int)Math.Ceiling(bottom))); + return area.Width > 0 && area.Height > 0 ? area : null; + } + + /// + /// The marks over , as on the whole × . + /// + private static MagickImage Paint(IEnumerable marks, int width, int height, SKRectI band) + { + using var bitmap = new SKBitmap(new SKImageInfo(band.Width, band.Height, SKColorType.Rgba8888, SKAlphaType.Premul)); using (var canvas = new SKCanvas(bitmap)) { canvas.Clear(SKColors.Transparent); - AnnotationPainter.Paint(canvas, options.Annotations!, width, height); + // Moved by whole pixels, so the bands meet without a seam. + canvas.Translate(-band.Left, -band.Top); + AnnotationPainter.Paint(canvas, marks, width, height); } // Skia draws premultiplied; ImageMagick reads straight alpha. - var info = new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Unpremul); + var info = new SKImageInfo(band.Width, band.Height, SKColorType.Rgba8888, SKAlphaType.Unpremul); var pixels = new byte[info.BytesSize]; var pinned = System.Runtime.InteropServices.GCHandle.Alloc(pixels, System.Runtime.InteropServices.GCHandleType.Pinned); try @@ -35,8 +114,8 @@ public static void Apply(IMagickImage image, ImageOptions options) { pinned.Free(); } - using var layer = new MagickImage(); - layer.ReadPixels(pixels, new PixelReadSettings((uint)width, (uint)height, StorageType.Char, PixelMapping.RGBA)); - image.Composite(layer, 0, 0, CompositeOperator.Over); + var layer = new MagickImage(); + layer.ReadPixels(pixels, new PixelReadSettings((uint)band.Width, (uint)band.Height, StorageType.Char, PixelMapping.RGBA)); + return layer; } } diff --git a/src/Filee.Engines/Annotations/LabelText.cs b/src/Filee.Engines/Annotations/LabelText.cs new file mode 100644 index 000000000..c4acbf2d4 --- /dev/null +++ b/src/Filee.Engines/Annotations/LabelText.cs @@ -0,0 +1,489 @@ +// The text of Annotate's labels and numbers, laid out as the window's text box shows it while it is typed: each letter +// in the first font of the UI's font chain that has it (the app passes the chain, its embedded Noto fonts first), else +// one the system finds; put in order by the Unicode bidirectional algorithm (UAX #9) for a paragraph without explicit +// embeddings or isolates (rules W1–W7, N0–N2, I1–I2, L2); each run shaped with HarfBuzz (GlyphOutlines) in its +// direction, so Arabic joins, brackets mirror and Indic and Thai marks sit in place. + +using System.Collections.Concurrent; +using System.Globalization; +using System.Text; +using Filee.Engines.Vector; +using SkiaSharp; + +namespace Filee.Engines.Annotations; + +internal static class LabelText +{ + // Per typeface: the chain's embedded "Noto Sans" and a system one of that name have different glyphs. + private static readonly GlyphOutlines Shaper = new(perTypeface: true); + private static readonly Lock ShaperLock = new(); + private static volatile Lazy _chain = new([]); + + /// + /// The fonts labels are drawn in, in order: the UI's font chain in bold, as the text box they are typed in uses it, + /// loaded the first time a label is laid out. Without it (the command line), the system's bold sans-serif and the + /// fonts the system finds. + /// + public static void UseFonts(Func> fonts) => _chain = new(() => + { + try + { + return [.. fonts()]; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Without the chain, labels are drawn in the system's fonts, rather than every label failing after. + return []; + } + }); + + /// + /// Reads the fonts labels will need, so the first label drawn doesn't wait for them (several MB of CJK fonts, and + /// on Linux every installed family): called off the UI thread when Annotate opens. + /// + public static void Warm() + { + _ = Fonts; + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS()) + _ = Installed.Value; + } + + private static SKTypeface[] Fonts => _chain.Value; + + /// The first font of the chain: the one a line's height is measured by. + public static SKTypeface Primary => Fonts is [var first, ..] ? first : SystemPrimary.Value; + + public static SKFont FontOf(SKTypeface face, float size) => + // Made bold by Skia when the face isn't (fonts the system finds for Arabic, Korean, Thai … are regular), as + // Avalonia makes them bold in the text box. + new(face, size) { Edging = SKFontEdging.Antialias, Subpixel = true, Embolden = face.FontWeight < 600 }; + + /// + /// Lays out from on , adding its glyphs to + /// when there is one, and returns how wide it is. Drawn as text, not outlines, so colour + /// emoji show. + /// + public static float Lay(string text, float size, SKTextBlobBuilder? builder, float x, float baseline) + { + var pen = x; + lock (ShaperLock) + { + foreach (var run in Runs(text)) + { + using var font = FontOf(run.Face, size); + // Unshaped only when the font's data can't be read. + var (glyphs, positions, width) = Shaper.Glyphs(run.Text, font, run.RightToLeft) + ?? (font.GetGlyphs(run.Text), font.GetGlyphPositions(run.Text), font.MeasureText(run.Text)); + if (builder is not null && glyphs.Length > 0) + { + var buffer = builder.AllocatePositionedRun(font, glyphs.Length); + buffer.SetGlyphs(glyphs); + buffer.SetPositions([.. positions.Select(p => new SKPoint(p.X + pen, p.Y + baseline))]); + } + pen += width; + } + } + return pen - x; + } + + /// + /// Where the baseline of goes for its line to be centred on : the + /// line reaches as far up and down as the tallest of its fonts, as the text box lays a line out (Arabic's font + /// reaches further down than Latin's). + /// + public static float BaselineFor(string text, float size, float middle) + { + List faces; + lock (ShaperLock) + faces = text.Length == 0 ? [Primary] : [.. Runs(text).Select(r => r.Face).Distinct()]; + float up = 0, down = 0; + foreach (var face in faces) + { + using var font = FontOf(face, size); + font.GetFontMetrics(out var metrics); + up = Math.Max(up, -metrics.Ascent); + down = Math.Max(down, metrics.Descent); + } + return middle + (up - down) / 2; + } + + /// + /// Whether a text reads right to left as a whole: its first strong letter does (Arabic, Hebrew …). + /// + public static bool IsRightToLeft(string text) + { + foreach (var rune in text.EnumerateRunes()) + { + switch (KindOf(rune)) + { + case Kind.R or Kind.AL: + return true; + case Kind.L: + return false; + } + } + return false; + } + + internal readonly record struct Run(string Text, SKTypeface Face, bool RightToLeft); + + /// + /// cut where its font or direction changes, in the order the parts are drawn, left to + /// right. Each letter is in the first font of the chain that has it, spaces too, as the text box picks them; marks + /// stay with the letter before them. + /// + internal static List Runs(string text) + { + var runes = text.EnumerateRunes().ToArray(); + if (runes.Length == 0) + return []; + var levels = Levels(runes, IsRightToLeft(text)); + + // Each letter's font: marks stay with the letter before them. + var faces = new SKTypeface[runes.Length]; + for (var i = 0; i < runes.Length; i++) + faces[i] = i > 0 && StaysWithLetterBefore(runes[i]) ? faces[i - 1] : FaceFor(runes[i].Value, i > 0 ? faces[i - 1] : null); + + var runs = new List<(int Start, int End, int Level)>(); + for (var i = 0; i < runes.Length; i++) + { + if (runs.Count > 0 && runs[^1].Level == levels[i] && faces[runs[^1].Start] == faces[i]) + runs[^1] = runs[^1] with { End = i + 1 }; + else + runs.Add((i, i + 1, levels[i])); + } + // From the highest level down to 1, every stretch of runs at that level or above turns around (UAX #9 L2). + for (var level = runs.Max(r => r.Level); level >= 1; level--) + { + for (var i = 0; i < runs.Count; i++) + { + if (runs[i].Level < level) + continue; + var end = i; + while (end + 1 < runs.Count && runs[end + 1].Level >= level) + end++; + runs.Reverse(i, end - i + 1); + i = end; + } + } + return [.. runs.Select(r => new Run(string.Concat(runes[r.Start..r.End].Select(x => x.ToString())), faces[r.Start], r.Level % 2 == 1))]; + } + + /// + /// A letter's bidirectional class (UAX #9, table 4): strong (L, R, AL), weak (EN, AN, ES, ET, CS, NSM, where a + /// boundary neutral such as a joiner counts as NSM) and neutral (whitespace and other neutrals together). + /// + private enum Kind { L, R, AL, EN, AN, ES, ET, CS, NSM, ON } + + /// + /// Each letter's embedding level, 0 to 2, by the rules of UAX #9 for a paragraph without explicit embeddings: W1–W7 + /// for weak types, N1–N2 for neutrals, I1–I2 for levels. A number after Arabic letters is an Arabic number, which + /// takes no sign or percent (W2, W5), so "خصم 50%" shows "%50" on the left as the text box does. + /// + private static int[] Levels(Rune[] runes, bool rightToLeft) + { + var n = runes.Length; + var sos = rightToLeft ? Kind.R : Kind.L; + var t = new Kind[n]; + for (var i = 0; i < n; i++) + t[i] = KindOf(runes[i]); + var original = (Kind[])t.Clone(); + // W1: a mark takes the type of what is before it. + for (var i = 0; i < n; i++) + { + if (t[i] == Kind.NSM) + t[i] = i > 0 ? t[i - 1] : sos; + } + // W2: a European number after Arabic letters is an Arabic number. W3: Arabic letters are right to left. + var strong = sos; + for (var i = 0; i < n; i++) + { + if (t[i] is Kind.L or Kind.R or Kind.AL) + strong = t[i]; + else if (t[i] == Kind.EN && strong == Kind.AL) + t[i] = Kind.AN; + } + for (var i = 0; i < n; i++) + { + if (t[i] == Kind.AL) + t[i] = Kind.R; + } + // W4: one separator between two numbers of the same kind joins them ("1,200", "3.5", "1-2" for European ones). + for (var i = 1; i + 1 < n; i++) + { + if (t[i] == Kind.ES && t[i - 1] == Kind.EN && t[i + 1] == Kind.EN) + t[i] = Kind.EN; + else if (t[i] == Kind.CS && t[i - 1] == t[i + 1] && t[i - 1] is Kind.EN or Kind.AN) + t[i] = t[i - 1]; + } + // W5: terminators ("%", "$", "#", "°") beside a European number belong to it. + for (var i = 0; i < n; i++) + { + if (t[i] != Kind.ET) + continue; + var end = i; + while (end < n && t[end] == Kind.ET) + end++; + if ((i > 0 && t[i - 1] == Kind.EN) || (end < n && t[end] == Kind.EN)) + { + for (var j = i; j < end; j++) + t[j] = Kind.EN; + } + i = end - 1; + } + // W6: separators and terminators left over are neutrals. W7: a European number after left-to-right letters is + // left to right. + strong = sos; + for (var i = 0; i < n; i++) + { + if (t[i] is Kind.ES or Kind.ET or Kind.CS) + t[i] = Kind.ON; + if (t[i] is Kind.L or Kind.R) + strong = t[i]; + else if (t[i] == Kind.EN && strong == Kind.L) + t[i] = Kind.L; + } + PairBrackets(runes, t, original, sos); + // N1, N2: neutrals between two the same way go that way (numbers count as right to left), else the paragraph's. + static bool IsRight(Kind kind) => kind is Kind.R or Kind.EN or Kind.AN; + for (var i = 0; i < n; i++) + { + if (t[i] != Kind.ON) + continue; + var end = i; + while (end < n && t[end] == Kind.ON) + end++; + var before = i > 0 ? IsRight(t[i - 1]) : rightToLeft; + var after = end < n ? IsRight(t[end]) : rightToLeft; + var way = before == after ? (before ? Kind.R : Kind.L) : sos; + for (var j = i; j < end; j++) + t[j] = way; + i = end - 1; + } + // I1, I2: on the paragraph's level, or one or two above it. + var levels = new int[n]; + for (var i = 0; i < n; i++) + { + levels[i] = rightToLeft + ? (t[i] == Kind.R ? 1 : 2) + : t[i] switch { Kind.L => 0, Kind.R => 1, _ => 2 }; + } + return levels; + } + + /// + /// N0: a pair of brackets goes the paragraph's way when there is a strong letter of that way inside it; else the + /// other way when there is one of that inside and before it too; else as N1 makes it. So "מחיר (USD) 5" keeps + /// "(USD)" whole, as the text box does. Marks after a bracket go as the bracket. + /// + private static void PairBrackets(Rune[] runes, Kind[] t, Kind[] original, Kind sos) + { + // BD16: the pairs, opening and closing, found with a stack of at most 63 open brackets. + var pairs = new List<(int Open, int Close)>(); + var open = new List<(int Closing, int At)>(); + for (var i = 0; i < runes.Length; i++) + { + if (t[i] != Kind.ON) + continue; + var c = Canonical(runes[i].Value); + if (Brackets.TryGetValue(c, out var closing)) + { + if (open.Count == 63) + break; + open.Add((closing, i)); + } + else if (Closings.Contains(c)) + { + var match = open.FindLastIndex(o => o.Closing == c); + if (match < 0) + continue; + pairs.Add((open[match].At, i)); + open.RemoveRange(match, open.Count - match); + } + } + static Kind? Strong(Kind kind) => kind switch { Kind.L => Kind.L, Kind.R or Kind.EN or Kind.AN => Kind.R, _ => null }; + foreach (var (start, end) in pairs.OrderBy(p => p.Open)) + { + var inside = Enumerable.Range(start + 1, end - start - 1).Select(i => Strong(t[i])).Where(k => k is not null).ToList(); + Kind way; + if (inside.Contains(sos)) + { + way = sos; + } + else if (inside.Count > 0) + { + var before = Enumerable.Range(0, start).Reverse().Select(i => Strong(t[i])).FirstOrDefault(k => k is not null) ?? sos; + way = before == sos ? sos : before; + } + else + { + continue; + } + foreach (var at in (int[])[start, end]) + { + t[at] = way; + for (var i = at + 1; i < runes.Length && original[i] == Kind.NSM; i++) + t[i] = way; + } + } + } + + /// Opening brackets and their closing ones (Unicode's Bidi_Paired_Bracket). + private static readonly Dictionary Brackets = new() + { + ['('] = ')', + ['['] = ']', + ['{'] = '}', + [0x0F3A] = 0x0F3B, + [0x0F3C] = 0x0F3D, + [0x169B] = 0x169C, + [0x2045] = 0x2046, + [0x207D] = 0x207E, + [0x208D] = 0x208E, + [0x2308] = 0x2309, + [0x230A] = 0x230B, + [0x2768] = 0x2769, + [0x276A] = 0x276B, + [0x276C] = 0x276D, + [0x276E] = 0x276F, + [0x2770] = 0x2771, + [0x2772] = 0x2773, + [0x2774] = 0x2775, + [0x27C5] = 0x27C6, + [0x27E6] = 0x27E7, + [0x27E8] = 0x27E9, + [0x27EA] = 0x27EB, + [0x27EC] = 0x27ED, + [0x27EE] = 0x27EF, + [0x2983] = 0x2984, + [0x2985] = 0x2986, + [0x2987] = 0x2988, + [0x2989] = 0x298A, + [0x298B] = 0x298C, + [0x2E22] = 0x2E23, + [0x2E24] = 0x2E25, + [0x2E26] = 0x2E27, + [0x2E28] = 0x2E29, + [0x3008] = 0x3009, + [0x300A] = 0x300B, + [0x300C] = 0x300D, + [0x300E] = 0x300F, + [0x3010] = 0x3011, + [0x3014] = 0x3015, + [0x3016] = 0x3017, + [0x3018] = 0x3019, + [0x301A] = 0x301B, + [0xFE59] = 0xFE5A, + [0xFE5B] = 0xFE5C, + [0xFE5D] = 0xFE5E, + [0xFF08] = 0xFF09, + [0xFF3B] = 0xFF3D, + [0xFF5B] = 0xFF5D, + [0xFF5F] = 0xFF60, + [0xFF62] = 0xFF63, + }; + + private static readonly HashSet Closings = [.. Brackets.Values]; + + /// The old angle brackets are the same as the CJK ones (canonically equivalent). + private static int Canonical(int c) => c switch { 0x2329 => 0x3008, 0x232A => 0x3009, _ => c }; + + private static Kind KindOf(Rune rune) + { + var c = rune.Value; + // The marks that only say which way: left to right, right to left, Arabic letter. + switch (c) + { + case 0x200E: + return Kind.L; + case 0x200F: + return Kind.R; + case 0x061C: + return Kind.AL; + } + var category = Rune.GetUnicodeCategory(rune); + if (category is UnicodeCategory.NonSpacingMark or UnicodeCategory.EnclosingMark or UnicodeCategory.Format) + return Kind.NSM; + if (c is >= 0x0660 and <= 0x0669 or 0x066B or 0x066C or >= 0x0600 and <= 0x0605) + return Kind.AN; + if (category == UnicodeCategory.DecimalDigitNumber) + return Kind.EN; + if (c is '+' or '-' or 0x2212) + return Kind.ES; + if (c is '#' or '$' or '%' or 0x00B0 or 0x066A or 0x2030 or 0x2031 || category == UnicodeCategory.CurrencySymbol) + return Kind.ET; + if (c is ',' or '.' or ':' or '/' or 0x00A0 or 0x060C or 0x202F) + return Kind.CS; + if (Rune.IsLetter(rune) || category == UnicodeCategory.SpacingCombiningMark) + { + // Hebrew, N'Ko, Samaritan, Mandaic and the like: right to left; Arabic, Syriac, Thaana: Arabic letters. + if (c is >= 0x0590 and <= 0x05FF or >= 0x07C0 and <= 0x085F or >= 0xFB1D and <= 0xFB4F or >= 0x10800 and <= 0x10FFF + or >= 0x1E800 and <= 0x1EDFF) + return Kind.R; + if (c is >= 0x0600 and <= 0x08FF or >= 0xFB50 and <= 0xFDFF or >= 0xFE70 and <= 0xFEFF or >= 0x1EE00 and <= 0x1EEFF) + return Kind.AL; + return Kind.L; + } + return Kind.ON; + } + + private static bool StaysWithLetterBefore(Rune rune) => Rune.GetUnicodeCategory(rune) + is UnicodeCategory.NonSpacingMark or UnicodeCategory.SpacingCombiningMark or UnicodeCategory.EnclosingMark + or UnicodeCategory.Format or UnicodeCategory.ModifierSymbol; + + /// + /// The first font of the chain that has the letter, else the one before it, else one the system finds. + /// + private static SKTypeface FaceFor(int letter, SKTypeface? before) + { + var chain = Fonts; + foreach (var face in chain.Length > 0 ? chain : [SystemPrimary.Value]) + { + if (face.ContainsGlyph(letter)) + return face; + } + return before?.ContainsGlyph(letter) == true ? before : FallbackFor(letter) ?? Primary; + } + + /// + /// The system's bold font, unless that is a serif or monospaced one (DejaVu Serif on Linux, where Skia's build here + /// can't ask for "sans-serif"); then the first common sans-serif family installed. + /// + private static readonly Lazy SystemPrimary = new(() => + { + var system = SKTypeface.FromFamilyName(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright) + ?? SKTypeface.Default; + if (!IsSerifOrMono(system.FamilyName)) + return system; + foreach (var family in (string[])["DejaVu Sans", "Noto Sans", "Liberation Sans", "Ubuntu", "Cantarell", "Arial"]) + { + if (SKFontManager.Default.MatchFamily(family, SKFontStyle.Bold) is { } face) + return face; + } + return system; + }); + + private static bool IsSerifOrMono(string family) => + (family.Contains("Serif", StringComparison.OrdinalIgnoreCase) && !family.Contains("Sans", StringComparison.OrdinalIgnoreCase)) + || family.Contains("Mono", StringComparison.OrdinalIgnoreCase); + + private static readonly ConcurrentDictionary Fallbacks = new(); + + /// + /// A bold font for a letter the chain lacks, or null when no installed font has it. Skia's Linux build here finds + /// fonts by family name but never by letter (MatchCharacter returns null even with a Korean font installed), so the + /// installed families are looked through for one that has it, sans-serif ones first. + /// + private static SKTypeface? FallbackFor(int codePoint) => Fallbacks.GetOrAdd(codePoint, static letter => + SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, letter) + // On Windows and macOS the system has looked through every font already. + ?? (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? null : Installed.Value.FirstOrDefault(face => face.ContainsGlyph(letter)))); + + /// + /// The installed families' bold faces, sans-serif first: opened once, not for every letter looked for (with many + /// fonts that took long, under the lock the window draws with). + /// + private static readonly Lazy Installed = new(() => + [.. SKFontManager.Default.FontFamilies.OrderBy(IsSerifOrMono).Select(family => SKFontManager.Default.MatchFamily(family, SKFontStyle.Bold)) + .OfType()]); +} diff --git a/src/Filee.Engines/Vector/GlyphOutlines.cs b/src/Filee.Engines/Vector/GlyphOutlines.cs index eb0af0136..fe6ce6925 100644 --- a/src/Filee.Engines/Vector/GlyphOutlines.cs +++ b/src/Filee.Engines/Vector/GlyphOutlines.cs @@ -1,12 +1,14 @@ // Text as glyph outlines: shaped with HarfBuzz the way Svg.Skia does it (adapted from SkiaModel.TextShaping.cs, -// Svg.Skia, MIT), so Arabic joins and Indic syllables come out right, and drawn as paths, so no font is embedded. -// Used for SVG text in PDFs (OutlinedTextPlayback) and for PDF watermarks and page numbers (PdfFinishing). +// Svg.Skia, MIT), so Arabic joins and Indic syllables come out right, and drawn as paths, so no font is embedded. Used +// for SVG text in PDFs (OutlinedTextPlayback), for PDF watermarks and page numbers (PdfFinishing) and for Annotate's +// labels (AnnotationPainter). using System.Runtime.InteropServices; using HarfBuzzSharp; using Buffer = HarfBuzzSharp.Buffer; using SKFont = SkiaSharp.SKFont; using SKPath = SkiaSharp.SKPath; +using SKPoint = SkiaSharp.SKPoint; using SKTextAlign = SkiaSharp.SKTextAlign; using SKTypeface = SkiaSharp.SKTypeface; @@ -19,24 +21,28 @@ internal sealed class GlyphOutlines : IDisposable private const int HarfBuzzScale = 512; private readonly Dictionary _shapers = new(StringComparer.Ordinal); + private readonly System.Runtime.CompilerServices.ConditionalWeakTable? _byTypeface; + + private sealed record Shaper(Font? Font); + + /// + /// : HarfBuzz fonts are kept per typeface object instead of per family and style, for + /// a caller that holds two faces of one name (an app's own "Noto Sans" and the system's), whose glyphs differ. + /// + public GlyphOutlines(bool perTypeface = false) + { + if (perTypeface) + _byTypeface = []; + } /// Shapes and appends its glyph outlines, aligned at (, ). public bool AppendShaped(SKPath path, string text, SKFont font, float x, float y, SKTextAlign align) { if (string.IsNullOrEmpty(text)) return true; - var shaper = ShaperFor(font.Typeface); - if (shaper is null) + if (Shape(text, font) is not (var infos, var positions)) return false; - using var buffer = new Buffer(); - buffer.ClusterLevel = ClusterLevel.Characters; - buffer.AddUtf16(text); - buffer.GuessSegmentProperties(); - shaper.Shape(buffer); - - var infos = buffer.GlyphInfos; - var positions = buffer.GlyphPositions; var scaleY = font.Size / HarfBuzzScale; var scaleX = scaleY * font.ScaleX; var width = 0f; @@ -58,6 +64,49 @@ public bool AppendShaped(SKPath path, string text, SKFont font, float x, float y return true; } + /// + /// shaped: its glyphs, where each is drawn from the start of the baseline, and how wide it + /// is; null when the font's data can't be read. For drawing as text, which keeps colour glyphs (emoji) that have no + /// outline. sets the direction (a bracket is mirrored right to left), else HarfBuzz + /// guesses it from the letters. + /// + public (ushort[] Glyphs, SKPoint[] Positions, float Width)? Glyphs(string text, SKFont font, bool? rightToLeft = null) + { + if (Shape(text, font, rightToLeft) is not (var infos, var positions)) + return null; + var scaleY = font.Size / HarfBuzzScale; + var scaleX = scaleY * font.ScaleX; + var glyphs = new ushort[infos.Length]; + var points = new SKPoint[infos.Length]; + float penX = 0, penY = 0; + for (var i = 0; i < infos.Length; i++) + { + glyphs[i] = (ushort)infos[i].Codepoint; + points[i] = new SKPoint(penX + positions[i].XOffset * scaleX, penY - positions[i].YOffset * scaleY); + penX += positions[i].XAdvance * scaleX; + penY -= positions[i].YAdvance * scaleY; + } + return (glyphs, points, penX); + } + + /// + /// The glyphs is drawn with and where, or null when the font's data can't be read. + /// + private (GlyphInfo[] Infos, GlyphPosition[] Positions)? Shape(string text, SKFont font, bool? rightToLeft = null) + { + var shaper = ShaperFor(font.Typeface); + if (shaper is null) + return null; + using var buffer = new Buffer(); + buffer.ClusterLevel = ClusterLevel.Characters; + buffer.AddUtf16(text); + buffer.GuessSegmentProperties(); + if (rightToLeft is { } rtl) + buffer.Direction = rtl ? Direction.RightToLeft : Direction.LeftToRight; + shaper.Shape(buffer); + return (buffer.GlyphInfos, buffer.GlyphPositions); + } + public static void AppendGlyph(SKPath path, SKFont font, ushort glyph, float x, float y) { using var outline = font.GetGlyphPath(glyph); @@ -68,21 +117,27 @@ public static void AppendGlyph(SKPath path, SKFont font, ushort glyph, float x, /// A HarfBuzz font for the typeface (cached), or null when its font data can't be read. private Font? ShaperFor(SKTypeface typeface) { + if (_byTypeface is not null) + return _byTypeface.GetValue(typeface, face => new Shaper(Load(face))).Font; var key = $"{typeface.FamilyName}|{typeface.FontWeight}|{typeface.FontWidth}|{typeface.FontSlant}"; if (_shapers.TryGetValue(key, out var cached)) return cached; + var font = Load(typeface); + _shapers[key] = font; + return font; + } - Font? font = null; + /// A HarfBuzz font made from the typeface's font data, or null when it can't be read. + private static Font? Load(SKTypeface typeface) + { var asset = typeface.OpenStream(out var index); - if (asset is not null) - { - using var blob = ToBlob(asset); - using var face = new Face(blob, index) { Index = index, UnitsPerEm = typeface.UnitsPerEm }; - font = new Font(face); - font.SetScale(HarfBuzzScale, HarfBuzzScale); - font.SetFunctionsOpenType(); - } - _shapers[key] = font; + if (asset is null) + return null; + using var blob = ToBlob(asset); + using var face = new Face(blob, index) { Index = index, UnitsPerEm = typeface.UnitsPerEm }; + var font = new Font(face); + font.SetScale(HarfBuzzScale, HarfBuzzScale); + font.SetFunctionsOpenType(); return font; } diff --git a/tests/Filee.App.Tests/AnnotateWindowTests.cs b/tests/Filee.App.Tests/AnnotateWindowTests.cs index ec372b5ab..baaf96b68 100644 --- a/tests/Filee.App.Tests/AnnotateWindowTests.cs +++ b/tests/Filee.App.Tests/AnnotateWindowTests.cs @@ -10,6 +10,7 @@ using Avalonia.Input; using Avalonia.Styling; using Avalonia.Threading; +using Avalonia.VisualTree; using Filee.App.Controls; using Filee.App.Services; using Filee.App.ViewModels; @@ -30,10 +31,10 @@ public void Dispose() try { Directory.Delete(_folder, recursive: true); } catch (IOException) { } } - private string Picture(string name) + private string Picture(string name, uint width = 800, uint height = 500) { var path = Path.Combine(_folder, name); - using var image = new MagickImage(new MagickColor("#FFFFFF"), 800, 500); + using var image = new MagickImage(new MagickColor("#FFFFFF"), width, height); new ImageMagick.Drawing.Drawables() .FillColor(new MagickColor("#F3F3F5")).Rectangle(0, 0, 800, 42) .FillColor(new MagickColor("#3E63DD")).RoundRectangle(560, 400, 760, 460, 10, 10) @@ -43,14 +44,14 @@ private string Picture(string name) return path; } - private (AnnotateWindow Window, AnnotateViewModel Vm, List<(string Path, Preset Conversion)> Exported) Open(int files = 1) + private (AnnotateWindow Window, AnnotateViewModel Vm, List<(string Path, Preset Conversion)> Exported) Open(int files = 1, uint width = 800, uint height = 500) { TestServices.EnsureInitialized("en"); var store = AppHost.Get(); var tool = store.FindPreset(BuiltInData.AnnotateId) ?? BuiltInData.AnnotatePresets().Single(); AnnotateTool.Remember(tool, AnnotateTool.Colors[0], AnnotationWidth.Medium); var exported = new List<(string, Preset)>(); - var paths = Enumerable.Range(1, files).Select(i => Picture($"shot{i}.png")).ToList(); + var paths = Enumerable.Range(1, files).Select(i => Picture($"shot{i}.png", width, height)).ToList(); var vm = new AnnotateViewModel(paths, tool, AppHost.Get(), store, _ => BuiltInData.SameAsSource, (path, conversion) => exported.Add((path, conversion))); var window = new AnnotateWindow { DataContext = vm, Width = 1040, Height = 720 }; @@ -207,6 +208,295 @@ public void Numbers_count_up_and_select_moves_resizes_restyles_and_deletes() } } + [AvaloniaFact] + public void Tab_picks_a_mark_and_the_arrows_move_it_by_pixels_of_the_picture() + { + var (window, vm, _) = Open(); // 800 × 500 + try + { + vm.Mode = AnnotateMode.Box; + Drag(window, (0.2, 0.2), (0.4, 0.4)); + Drag(window, (0.6, 0.6), (0.8, 0.8)); + Assert.Equal(-1, vm.Marks.Selected); + + Key(window, Avalonia.Input.Key.Tab); + Assert.Equal((AnnotateMode.Select, 0), (vm.Mode, vm.Marks.Selected)); + Key(window, Avalonia.Input.Key.Tab); + Assert.Equal(1, vm.Marks.Selected); + Key(window, Avalonia.Input.Key.Tab, RawInputModifiers.Shift); + Assert.Equal(0, vm.Marks.Selected); + + var before = vm.Marks.Marks[0].Points[0]; + Key(window, Avalonia.Input.Key.Right); + Key(window, Avalonia.Input.Key.Down, RawInputModifiers.Shift); + var after = vm.Marks.Marks[0].Points[0]; + Assert.Equal(1.0 / 800, after.X - before.X, 6); + Assert.Equal(10.0 / 500, after.Y - before.Y, 6); + // Each move is one undo step. + vm.UndoCommand.Execute(null); + Assert.Equal(before.Y, vm.Marks.Marks[0].Points[0].Y, 6); + + // It stays on the picture: far to the left, it stops at the edge. + for (var i = 0; i < 30; i++) + Key(window, Avalonia.Input.Key.Left, RawInputModifiers.Shift); + Assert.Equal(0, vm.Marks.Marks[0].Points.Min(p => p.X), 6); + } + finally + { + window.Close(); + } + } + + [AvaloniaFact] + public void Past_the_last_mark_Tab_goes_on_into_the_bar_and_Esc_comes_back_to_the_picture() + { + var (window, vm, _) = Open(); + try + { + vm.Mode = AnnotateMode.Box; + Drag(window, (0.2, 0.2), (0.4, 0.4)); + Drag(window, (0.6, 0.6), (0.8, 0.8)); + + Key(window, Avalonia.Input.Key.Tab); + Key(window, Avalonia.Input.Key.Tab); + Assert.Equal(1, vm.Marks.Selected); + Key(window, Avalonia.Input.Key.Tab); + // No mark picked, and the focus on the bar's first tool (not the window's close button, which Enter would + // press, closing without saving). + Assert.Equal(-1, vm.Marks.Selected); + Assert.Same(window.FindControl("SelectTool"), window.FocusManager!.GetFocusedElement()); + // The arrows go along the tools and choose; Enter there saves nothing, Space chooses. + Key(window, Avalonia.Input.Key.Right); + Assert.Same(window.FindControl("PenTool"), window.FocusManager.GetFocusedElement()); + Assert.Equal(AnnotateMode.Pen, vm.Mode); + Key(window, Avalonia.Input.Key.Left); + Assert.Equal(AnnotateMode.Select, vm.Mode); + // Enter on a tool still saves (Space chooses). + Assert.True(vm.SaveCommand.CanExecute(null)); + + // Esc leaves the bar for the picture (it doesn't close the window), and Tab picks marks again. + Key(window, Avalonia.Input.Key.Escape); + Assert.True(window.IsVisible); + Assert.Null(window.FocusManager.GetFocusedElement()); + Key(window, Avalonia.Input.Key.Tab); + Assert.Equal(0, vm.Marks.Selected); + // Shift+Tab before the first mark: into the bar from its end, at Save. + Key(window, Avalonia.Input.Key.Tab, RawInputModifiers.Shift); + Assert.Same(window.FindControl