diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 0fc1b0509..6aaf84959 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -148,6 +148,22 @@ 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 60) 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, always at the picture's own size in pixels. The
+ window's `AnnotationCanvas` calls it on Avalonia's own Skia canvas (a custom draw operation through
+ `ISkiaSharpApiLeaseFeature`), scaled to where the picture is shown, and `Annotator` on layers over the part the marks
+ cover, a band at a time, laid over the picture in `ImageEncoder.ApplyOptions` after hidden areas (converted to a CMYK
+ picture's colours; a greyscale picture becomes a colour one): the preview is the file. Text is shaped with HarfBuzz
+ (`Vector/GlyphOutlines`) in the system's bold sans-serif, each run of letters it lacks in a font the system finds.
+- `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 160f4ef08..12e3aaf28 100644
--- a/src/Filee.App/Assets/i18n/ar.json
+++ b/src/Filee.App/Assets/i18n/ar.json
@@ -390,6 +390,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": "انعكاس عمودي",
@@ -927,6 +928,35 @@
"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.red": "أحمر",
+ "annotate.orange": "برتقالي",
+ "annotate.yellow": "أصفر",
+ "annotate.green": "أخضر",
+ "annotate.blue": "أزرق",
+ "annotate.black": "أسود",
+ "annotate.undo": "تراجع (Ctrl+Z)",
+ "annotate.redo": "إعادة (Ctrl+Shift+Z)",
+ "annotate.remove": "إزالة العلامة المحددة (Del)",
+ "annotate.keys": "V للتحديد · Tab لاختيار علامة والأسهم لتحريكها · 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 85a07f2f6..ffbaf2541 100644
--- a/src/Filee.App/Assets/i18n/de.json
+++ b/src/Filee.App/Assets/i18n/de.json
@@ -390,6 +390,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",
@@ -927,6 +928,35 @@
"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.red": "Rot",
+ "annotate.orange": "Orange",
+ "annotate.yellow": "Gelb",
+ "annotate.green": "Grün",
+ "annotate.blue": "Blau",
+ "annotate.black": "Schwarz",
+ "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 · Tab wählt eine Markierung, Pfeiltasten verschieben sie · 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 0e4ace308..c13ed7c1f 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -400,6 +400,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",
@@ -942,6 +943,35 @@
"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.red": "Red",
+ "annotate.orange": "Orange",
+ "annotate.yellow": "Yellow",
+ "annotate.green": "Green",
+ "annotate.blue": "Blue",
+ "annotate.black": "Black",
+ "annotate.undo": "Undo (Ctrl+Z)",
+ "annotate.redo": "Redo (Ctrl+Shift+Z)",
+ "annotate.remove": "Remove the selected mark (Del)",
+ "annotate.keys": "V selects · Tab picks a mark, arrows move it · 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 ee5de439f..2c176a684 100644
--- a/src/Filee.App/Assets/i18n/es.json
+++ b/src/Filee.App/Assets/i18n/es.json
@@ -390,6 +390,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",
@@ -927,6 +928,35 @@
"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.red": "Rojo",
+ "annotate.orange": "Naranja",
+ "annotate.yellow": "Amarillo",
+ "annotate.green": "Verde",
+ "annotate.blue": "Azul",
+ "annotate.black": "Negro",
+ "annotate.undo": "Deshacer (Ctrl+Z)",
+ "annotate.redo": "Rehacer (Ctrl+Mayús+Z)",
+ "annotate.remove": "Quitar la marca seleccionada (Supr)",
+ "annotate.keys": "V selecciona · Tab elige una marca, las flechas la mueven · 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 1c33e0dbb..59dc91144 100644
--- a/src/Filee.App/Assets/i18n/fr.json
+++ b/src/Filee.App/Assets/i18n/fr.json
@@ -400,6 +400,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",
@@ -942,6 +943,35 @@
"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.red": "Rouge",
+ "annotate.orange": "Orange",
+ "annotate.yellow": "Jaune",
+ "annotate.green": "Vert",
+ "annotate.blue": "Bleu",
+ "annotate.black": "Noir",
+ "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 · Tab choisit une marque, les flèches la déplacent · 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 a4414c46f..542b9d4bc 100644
--- a/src/Filee.App/Assets/i18n/hi.json
+++ b/src/Filee.App/Assets/i18n/hi.json
@@ -390,6 +390,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": "खड़ा पलटें",
@@ -927,6 +928,35 @@
"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.red": "लाल",
+ "annotate.orange": "नारंगी",
+ "annotate.yellow": "पीला",
+ "annotate.green": "हरा",
+ "annotate.blue": "नीला",
+ "annotate.black": "काला",
+ "annotate.undo": "पूर्ववत (Ctrl+Z)",
+ "annotate.redo": "फिर से करें (Ctrl+Shift+Z)",
+ "annotate.remove": "चुना निशान हटाएँ (Del)",
+ "annotate.keys": "V चुनता है · Tab निशान चुनता है, तीर उसे खिसकाते हैं · 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 cf0e344b6..c9e592166 100644
--- a/src/Filee.App/Assets/i18n/id.json
+++ b/src/Filee.App/Assets/i18n/id.json
@@ -390,6 +390,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",
@@ -927,6 +928,35 @@
"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.red": "Merah",
+ "annotate.orange": "Oranye",
+ "annotate.yellow": "Kuning",
+ "annotate.green": "Hijau",
+ "annotate.blue": "Biru",
+ "annotate.black": "Hitam",
+ "annotate.undo": "Urungkan (Ctrl+Z)",
+ "annotate.redo": "Ulangi (Ctrl+Shift+Z)",
+ "annotate.remove": "Hapus tanda yang dipilih (Del)",
+ "annotate.keys": "V memilih · Tab memilih tanda, panah menggesernya · 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 3cf6b96ad..38ebbc16e 100644
--- a/src/Filee.App/Assets/i18n/it.json
+++ b/src/Filee.App/Assets/i18n/it.json
@@ -390,6 +390,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",
@@ -927,6 +928,35 @@
"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.red": "Rosso",
+"annotate.orange": "Arancione",
+"annotate.yellow": "Giallo",
+"annotate.green": "Verde",
+"annotate.blue": "Blu",
+"annotate.black": "Nero",
+"annotate.undo": "Annulla (Ctrl+Z)",
+"annotate.redo": "Ripeti (Ctrl+Maiusc+Z)",
+"annotate.remove": "Rimuovi il segno selezionato (Canc)",
+"annotate.keys": "V seleziona · Tab sceglie un segno, le frecce lo spostano · 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 448162e2c..33fc2c144 100644
--- a/src/Filee.App/Assets/i18n/ja.json
+++ b/src/Filee.App/Assets/i18n/ja.json
@@ -390,6 +390,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": "垂直方向に反転",
@@ -927,6 +928,35 @@
"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.red": "赤",
+ "annotate.orange": "オレンジ",
+ "annotate.yellow": "黄",
+ "annotate.green": "緑",
+ "annotate.blue": "青",
+ "annotate.black": "黒",
+ "annotate.undo": "元に戻す (Ctrl+Z)",
+ "annotate.redo": "やり直し (Ctrl+Shift+Z)",
+ "annotate.remove": "選択したマークを削除 (Del)",
+ "annotate.keys": "V 選択 · Tab で選択、矢印で移動 · 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 88a2c0b06..8d57b94a0 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -400,6 +400,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": "상하 반전",
@@ -942,6 +943,35 @@
"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.red": "빨강",
+ "annotate.orange": "주황",
+ "annotate.yellow": "노랑",
+ "annotate.green": "초록",
+ "annotate.blue": "파랑",
+ "annotate.black": "검정",
+ "annotate.undo": "되돌리기 (Ctrl+Z)",
+ "annotate.redo": "다시 실행 (Ctrl+Shift+Z)",
+ "annotate.remove": "선택한 표시 지우기 (Del)",
+ "annotate.keys": "V 선택 · Tab 표시 고르기, 화살표 옮기기 · 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 87ad2d56c..4ebbd105f 100644
--- a/src/Filee.App/Assets/i18n/ne.json
+++ b/src/Filee.App/Assets/i18n/ne.json
@@ -390,6 +390,7 @@
"preset.rotate_180": "१८०° घुमाउनुहोस्",
"preset.rotate_180.short": "१८०°",
"preset.group_flip": "उल्ट्याउनुहोस्",
+ "preset.annotate": "टिप्पणी",
"preset.flip_horizontal": "तेर्सो उल्ट्याउनुहोस्",
"preset.flip_horizontal.short": "तेर्सो",
"preset.flip_vertical": "ठाडो उल्ट्याउनुहोस्",
@@ -927,6 +928,35 @@
"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.red": "रातो",
+ "annotate.orange": "सुन्तला",
+ "annotate.yellow": "पहेँलो",
+ "annotate.green": "हरियो",
+ "annotate.blue": "निलो",
+ "annotate.black": "कालो",
+ "annotate.undo": "पूर्ववत (Ctrl+Z)",
+ "annotate.redo": "फेरि गर्नुहोस् (Ctrl+Shift+Z)",
+ "annotate.remove": "छानिएको चिन्ह हटाउनुहोस् (Del)",
+ "annotate.keys": "V छान्छ · Tab चिन्ह छान्छ, तीरले सार्छ · 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 29ffeba15..5ce6887e6 100644
--- a/src/Filee.App/Assets/i18n/pl.json
+++ b/src/Filee.App/Assets/i18n/pl.json
@@ -400,6 +400,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",
@@ -942,6 +943,35 @@
"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.red": "Czerwony",
+ "annotate.orange": "Pomarańczowy",
+ "annotate.yellow": "Żółty",
+ "annotate.green": "Zielony",
+ "annotate.blue": "Niebieski",
+ "annotate.black": "Czarny",
+ "annotate.undo": "Cofnij (Ctrl+Z)",
+ "annotate.redo": "Ponów (Ctrl+Shift+Z)",
+ "annotate.remove": "Usuń zaznaczony znak (Del)",
+ "annotate.keys": "V zaznacza · Tab wybiera znak, strzałki go przesuwają · 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 7148afbde..3866137f2 100644
--- a/src/Filee.App/Assets/i18n/pt-BR.json
+++ b/src/Filee.App/Assets/i18n/pt-BR.json
@@ -400,6 +400,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",
@@ -942,6 +943,35 @@
"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.red": "Vermelho",
+ "annotate.orange": "Laranja",
+ "annotate.yellow": "Amarelo",
+ "annotate.green": "Verde",
+ "annotate.blue": "Azul",
+ "annotate.black": "Preto",
+ "annotate.undo": "Desfazer (Ctrl+Z)",
+ "annotate.redo": "Refazer (Ctrl+Shift+Z)",
+ "annotate.remove": "Remover a marca selecionada (Del)",
+ "annotate.keys": "V seleciona · Tab escolhe uma marca, as setas a movem · 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 511eca0d3..d25bab36a 100644
--- a/src/Filee.App/Assets/i18n/pt-PT.json
+++ b/src/Filee.App/Assets/i18n/pt-PT.json
@@ -400,6 +400,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",
@@ -942,6 +943,35 @@
"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.red": "Vermelho",
+ "annotate.orange": "Laranja",
+ "annotate.yellow": "Amarelo",
+ "annotate.green": "Verde",
+ "annotate.blue": "Azul",
+ "annotate.black": "Preto",
+ "annotate.undo": "Anular (Ctrl+Z)",
+ "annotate.redo": "Refazer (Ctrl+Shift+Z)",
+ "annotate.remove": "Remover a marca selecionada (Del)",
+ "annotate.keys": "V seleciona · Tab escolhe uma marca, as setas movem-na · 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 c7225a78f..0ebb46e5f 100644
--- a/src/Filee.App/Assets/i18n/ru.json
+++ b/src/Filee.App/Assets/i18n/ru.json
@@ -400,6 +400,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": "Отразить по вертикали",
@@ -942,6 +943,35 @@
"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.red": "Красный",
+ "annotate.orange": "Оранжевый",
+ "annotate.yellow": "Жёлтый",
+ "annotate.green": "Зелёный",
+ "annotate.blue": "Синий",
+ "annotate.black": "Чёрный",
+ "annotate.undo": "Отменить (Ctrl+Z)",
+ "annotate.redo": "Повторить (Ctrl+Shift+Z)",
+ "annotate.remove": "Удалить выбранную пометку (Del)",
+ "annotate.keys": "V выбор · Tab выбирает пометку, стрелки двигают · 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 6edb89604..bb4799280 100644
--- a/src/Filee.App/Assets/i18n/th.json
+++ b/src/Filee.App/Assets/i18n/th.json
@@ -390,6 +390,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": "พลิกแนวตั้ง",
@@ -927,6 +928,35 @@
"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.red": "แดง",
+ "annotate.orange": "ส้ม",
+ "annotate.yellow": "เหลือง",
+ "annotate.green": "เขียว",
+ "annotate.blue": "น้ำเงิน",
+ "annotate.black": "ดำ",
+ "annotate.undo": "เลิกทำ (Ctrl+Z)",
+ "annotate.redo": "ทำซ้ำ (Ctrl+Shift+Z)",
+ "annotate.remove": "ลบเครื่องหมายที่เลือก (Del)",
+ "annotate.keys": "V เลือก · Tab เลือกเครื่องหมาย ลูกศรเลื่อน · 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 f28e3fb8b..282c43857 100644
--- a/src/Filee.App/Assets/i18n/tr.json
+++ b/src/Filee.App/Assets/i18n/tr.json
@@ -390,6 +390,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",
@@ -927,6 +928,35 @@
"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.red": "Kırmızı",
+ "annotate.orange": "Turuncu",
+ "annotate.yellow": "Sarı",
+ "annotate.green": "Yeşil",
+ "annotate.blue": "Mavi",
+ "annotate.black": "Siyah",
+ "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 · Tab işaret seçer, oklar taşır · 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 0a456e97c..550607853 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -400,6 +400,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": "垂直翻转",
@@ -942,6 +943,35 @@
"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.red": "红色",
+ "annotate.orange": "橙色",
+ "annotate.yellow": "黄色",
+ "annotate.green": "绿色",
+ "annotate.blue": "蓝色",
+ "annotate.black": "黑色",
+ "annotate.undo": "撤销 (Ctrl+Z)",
+ "annotate.redo": "重做 (Ctrl+Shift+Z)",
+ "annotate.remove": "删除所选标记 (Del)",
+ "annotate.keys": "V 选择 · Tab 选择标记,方向键移动 · 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 eb733ba80..c984280cb 100644
--- a/src/Filee.App/Assets/i18n/zh-TW.json
+++ b/src/Filee.App/Assets/i18n/zh-TW.json
@@ -400,6 +400,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": "垂直翻轉",
@@ -942,6 +943,35 @@
"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.red": "紅色",
+ "annotate.orange": "橙色",
+ "annotate.yellow": "黃色",
+ "annotate.green": "綠色",
+ "annotate.blue": "藍色",
+ "annotate.black": "黑色",
+ "annotate.undo": "復原 (Ctrl+Z)",
+ "annotate.redo": "重做 (Ctrl+Shift+Z)",
+ "annotate.remove": "移除所選標記 (Del)",
+ "annotate.keys": "V 選取 · Tab 選取標記,方向鍵移動 · 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..c7e5b707d
--- /dev/null
+++ b/src/Filee.App/Controls/AnnotationCanvas.cs
@@ -0,0 +1,533 @@
+// The "Annotate" window's picture and its marks: the marks are painted by AnnotationPainter on Avalonia's own Skia
+// canvas in the picture's own pixels, scaled to the picture's place, so they look exactly as they will in the file (a
+// small picture shown large gets the file's smallest line, text and number, not the screen's); 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);
+
+ ///
+ /// The picture's own size in pixels, upright, which the file's marks are painted at; the preview can be smaller.
+ ///
+ public static readonly StyledProperty PictureSizeProperty =
+ AvaloniaProperty.Register(nameof(PictureSize));
+
+ 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 PixelSize PictureSize { get => GetValue(PictureSizeProperty); set => SetValue(PictureSizeProperty, 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, PictureSizeProperty, 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);
+ }
+
+ /// The size the marks are painted at: , else the preview's.
+ internal Size Pixels => PictureSize is { Width: > 0, Height: > 0 } size ? new Size(size.Width, size.Height)
+ : SourceImage is { } image ? new Size(image.PixelSize.Width, image.PixelSize.Height) : default;
+
+ /// The screen rectangle covers.
+ internal Rect BoundsOf(Annotation mark) => BoundsOf(mark, CalculateImageBounds(), Pixels);
+
+ // ───────── 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;
+ var pixels = Pixels;
+ context.Custom(new MarksDrawing(new Rect(Bounds.Size), picture, pixels, [.. marks.Marks.Where((_, i) => i != TypingIndex).Select(m => m.Clone())]));
+ if (ShowsSelection && marks.Selected != TypingIndex)
+ DrawSelection(context, marks.SelectedMark!, picture, pixels);
+ }
+
+ ///
+ /// 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 at the picture's size and scaled to fit.
+ ///
+ private sealed class MarksDrawing(Rect bounds, Rect picture, Size pixels, 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);
+ canvas.Scale((float)(picture.Width / pixels.Width), (float)(picture.Height / pixels.Height));
+ AnnotationPainter.Paint(canvas, marks, (float)pixels.Width, (float)pixels.Height);
+ canvas.Restore();
+ }
+ }
+
+ private void DrawSelection(DrawingContext context, Annotation mark, Rect picture, Size pixels)
+ {
+ 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, pixels).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: worked out in the picture's own , as it is painted,
+ /// and scaled to the picture's place.
+ ///
+ internal static Rect BoundsOf(Annotation mark, Rect picture, Size pixels)
+ {
+ float w = (float)pixels.Width, h = (float)pixels.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;
+ }
+ double sx = picture.Width / w, sy = picture.Height / h;
+ return new Rect(picture.X + rect.Left * sx, picture.Y + rect.Top * sy, rect.Width * sx, rect.Height * sy);
+ }
+
+ /// The mark on top under , -1 for none; the selected one first.
+ internal static int HitTest(IReadOnlyList marks, int selected, Point point, Rect picture, Size pixels)
+ {
+ if (selected >= 0 && selected < marks.Count && Hits(marks[selected], point, picture, pixels))
+ return selected;
+ for (var i = marks.Count - 1; i >= 0; i--)
+ {
+ if (Hits(marks[i], point, picture, pixels))
+ return i;
+ }
+ return -1;
+ }
+
+ private static bool Hits(Annotation mark, Point point, Rect picture, Size pixels)
+ {
+ if (mark.Points.Count == 0)
+ return false;
+ var slop = HitSlop + AnnotationPainter.StrokeOf(mark.Width, (float)pixels.Width, (float)pixels.Height) * picture.Width / pixels.Width / 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, pixels).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, Pixels);
+ 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) => TextRequested?.Invoke(this, RequestFor(index, at, picture));
+
+ ///
+ /// Text mark (or a new one, -1) at : where its box goes on screen
+ /// now, and its size.
+ ///
+ internal TextRequest RequestFor(int index, AnnotationPoint at) => RequestFor(index, at, CalculateImageBounds());
+
+ private TextRequest RequestFor(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 = " " };
+ var pixels = Pixels;
+ return new TextRequest(index, at, BoundsOf(empty, picture, pixels),
+ AnnotationPainter.FontSizeOf(width, (float)pixels.Width, (float)pixels.Height) * picture.Width / pixels.Width);
+ }
+
+ 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..9731c5e59
--- /dev/null
+++ b/src/Filee.App/Controls/AnnotationSet.cs
@@ -0,0 +1,197 @@
+// 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();
+ }
+
+ ///
+ /// The next (or previous) mark in the order they were drawn, round and round: from none, the first (or the last).
+ ///
+ public void SelectNext(int step)
+ {
+ if (_marks.Count == 0)
+ return;
+ Select(Selected < 0
+ ? (step >= 0 ? 0 : _marks.Count - 1)
+ : ((Selected + step) % _marks.Count + _marks.Count) % _marks.Count);
+ }
+
+ ///
+ /// Moves the selected mark by whole pixels of a × picture, as
+ /// far as it stays on the picture (one undo step).
+ ///
+ public void Nudge(int dx, int dy, int width, int height)
+ {
+ if (SelectedMark is not { } mark || width <= 0 || height <= 0)
+ return;
+ var moveX = Math.Clamp((double)dx / width, -mark.Points.Min(p => p.X), 1 - mark.Points.Max(p => p.X));
+ var moveY = Math.Clamp((double)dy / height, -mark.Points.Min(p => p.Y), 1 - mark.Points.Max(p => p.Y));
+ if (moveX == 0 && moveY == 0)
+ return;
+ Change(Selected, moved =>
+ {
+ for (var i = 0; i < moved.Points.Count; i++)
+ moved.Points[i] = new AnnotationPoint { X = moved.Points[i].X + moveX, Y = moved.Points[i].Y + moveY };
+ });
+ }
+
+ /// 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/AnnotationFonts.cs b/src/Filee.App/Services/AnnotationFonts.cs
new file mode 100644
index 000000000..5a2e3d6d2
--- /dev/null
+++ b/src/Filee.App/Services/AnnotationFonts.cs
@@ -0,0 +1,97 @@
+// The fonts Annotate's labels are drawn in, handed to the painter (Filee.Engines): the UI's font chain (SoftPalette) in
+// bold, as the box a label is typed in shows it, so the label drawn is the label typed. The embedded Noto fonts are
+// read the first time a label is drawn, their bold faces only.
+
+using Avalonia.Media;
+using Avalonia.Platform;
+using Filee.Engines.Annotations;
+using SkiaSharp;
+
+namespace Filee.App.Services;
+
+internal static class AnnotationFonts
+{
+ private const string EmbeddedPrefix = "fonts:Filee#";
+
+ private static readonly Lazy> Embedded = new(LoadEmbedded);
+ private static string? _language;
+
+ ///
+ /// Gives the painter the chain of 's UI font, in bold: at start and when the language
+ /// changes, so a conversion run later (a queued one, a retry) draws its labels as the window showed them. The fonts
+ /// are read the first time a label is drawn.
+ ///
+ public static void Use(string language)
+ {
+ if (_language == language)
+ return;
+ _language = language;
+ AnnotationPainter.UseFonts(() => Chain(language));
+ var family = SoftPalette.FontFamilyFor(language);
+ AnnotationPainter.UseFallback(codePoint => FallbackFor(codePoint, family));
+ }
+
+ ///
+ /// The face the text box draws a letter in that its first font lacks, and whether it makes it bold: Avalonia's font
+ /// manager asked as its text layout asks it (the whole chain, bold, the UI's language), then that face, an embedded
+ /// one or the system's, by its name.
+ ///
+ private static (SKTypeface Face, bool MadeBold)? FallbackFor(int codePoint, FontFamily family)
+ {
+ try
+ {
+ if (!FontManager.Current.TryMatchCharacter(codePoint, FontStyle.Normal, FontWeight.Bold, FontStretch.Normal, family, null, out var typeface)
+ || !FontManager.Current.TryGetGlyphTypeface(typeface, out var glyphs))
+ return null;
+ var weight = (int)glyphs.Weight;
+ var face = Embedded.Value.Where(f => f.FamilyName == glyphs.FamilyName).OrderBy(f => Math.Abs(f.FontWeight - weight)).FirstOrDefault()
+ ?? SKFontManager.Default.MatchFamily(glyphs.FamilyName, new SKFontStyle((SKFontStyleWeight)weight,
+ (SKFontStyleWidth)(int)glyphs.Stretch, glyphs.Style == FontStyle.Normal ? SKFontStyleSlant.Upright : SKFontStyleSlant.Italic));
+ return face is null ? null : (face, glyphs.FontSimulations.HasFlag(FontSimulations.Bold));
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ return null;
+ }
+ }
+
+ private static List Chain(string language)
+ {
+ var chain = new List();
+ foreach (var family in SoftPalette.FontFamilies(language).Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
+ {
+ // "sans-serif" ends the chain: what no family has, the painter finds as the text box's fallback does.
+ var face = family.StartsWith(EmbeddedPrefix, StringComparison.Ordinal)
+ ? Embedded.Value.FirstOrDefault(f => f.FamilyName == family[EmbeddedPrefix.Length..])
+ : family == "sans-serif" ? null
+ : SKFontManager.Default.MatchFamily(family, SKFontStyle.Bold) is { } system
+ && string.Equals(system.FamilyName, family, StringComparison.OrdinalIgnoreCase) ? system : null;
+ if (face is not null && !chain.Contains(face))
+ chain.Add(face);
+ }
+ return chain;
+ }
+
+ /// The embedded fonts' bold faces; none when they weren't fetched (build/fetch-fonts.ps1).
+ private static IReadOnlyList LoadEmbedded()
+ {
+ var faces = new List();
+ try
+ {
+ foreach (var uri in AssetLoader.GetAssets(new Uri("avares://Filee/Assets/Fonts"), null))
+ {
+ if (!uri.AbsolutePath.Contains("-Bold.", StringComparison.Ordinal))
+ continue;
+ using var stream = AssetLoader.Open(uri);
+ using var data = SKData.Create(stream);
+ if (SKTypeface.FromData(data) is { } face)
+ faces.Add(face);
+ }
+ }
+ catch (Exception ex) when (ex is IOException or InvalidOperationException or FileNotFoundException)
+ {
+ // Without them the system's fonts are used, as the UI does.
+ }
+ return faces;
+ }
+}
diff --git a/src/Filee.App/Services/PresetAvailability.cs b/src/Filee.App/Services/PresetAvailability.cs
index 21249dbfd..1422d32b8 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/ThemeService.cs b/src/Filee.App/Services/ThemeService.cs
index 55fc9399b..28b9bae73 100644
--- a/src/Filee.App/Services/ThemeService.cs
+++ b/src/Filee.App/Services/ThemeService.cs
@@ -96,6 +96,8 @@ public void Apply(ThemeSettings theme, string language, CustomTheme? custom = nu
// Radii, fonts, flow direction
SoftPalette.ApplyCornerRadius(res, theme.CornerRadius);
res["AppFontFamily"] = SoftPalette.FontFamilyFor(language);
+ // Annotate draws its labels in the same fonts (read when a label is first drawn).
+ AnnotationFonts.Use(language);
SoftPalette.ApplyFlowDirection(res, language);
if (theme.Style == ThemeStyle.MaterialYou)
diff --git a/src/Filee.App/Services/WindowService.cs b/src/Filee.App/Services/WindowService.cs
index b60f94c89..5af7c97e2 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..468d57aa5
--- /dev/null
+++ b/src/Filee.App/ViewModels/AnnotateViewModel.cs
@@ -0,0 +1,408 @@
+// 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;
+
+ /// How each picture went: saved, skipped or failed (none yet while it is open).
+ private readonly CropItemState?[] _outcomes;
+
+ 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;
+ _outcomes = new CropItemState?[files.Count];
+ Swatches = [.. AnnotateTool.Colors.Select((c, i) => new AnnotateSwatch(c, AnnotateTool.ColorNameKeys[i], this) { Name = loc[AnnotateTool.ColorNameKeys[i]] })];
+ Marks.Changed += OnMarksChanged;
+ loc.LanguageChanged += OnLanguageChanged;
+ // The fonts labels are drawn in, read now off the UI thread rather than on the first text.
+ _ = Task.Run(Filee.Engines.Annotations.AnnotationPainter.WarmFonts);
+ 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]
+ [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
+ private Bitmap? _previewImage;
+
+ /// The picture's own size, upright: the window paints the marks at it, as the file gets them.
+ [ObservableProperty] private Avalonia.PixelSize _pictureSize;
+ [ObservableProperty] private string _statusNotice = "";
+
+ ///
+ /// All pictures are done and some could not be opened or saved: the window stays open on and
+ /// until it is closed (as "Pixelate / blur" does).
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsEditing))]
+ private bool _isFinished;
+
+ [ObservableProperty] private string _summary = "";
+ [ObservableProperty] private int _doneCount;
+ [ObservableProperty] private int _skippedCount;
+ [ObservableProperty] private int _failedCount;
+
+ /// "file: reason" for each picture that could not be opened or saved.
+ public System.Collections.ObjectModel.ObservableCollection Problems { get; } = [];
+
+ public bool IsEditing => !IsFinished;
+
+ [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));
+ SaveCommand.NotifyCanExecuteChanged();
+ // 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, or shows the summary when a
+ /// picture could not be opened or saved.
+ ///
+ public async Task LoadFileAsync(int index)
+ {
+ if (_closed)
+ return;
+ if (index >= _files.Count)
+ {
+ Finish();
+ return;
+ }
+ IsBusy = true;
+ CurrentIndex = index;
+ var path = _files[index];
+ CurrentFileName = Path.GetFileName(path);
+ CounterText = _loc.Format("crop.page_counter", index + 1, _files.Count);
+ OnPropertyChanged(nameof(SaveText));
+ Marks.Reset();
+ try
+ {
+ var (preview, width, height) = await HideService.LoadPreviewAsync(path, _preset);
+ Bitmap bitmap;
+ using (preview)
+ bitmap = await Task.Run(() => HideService.ToBitmap(preview));
+ if (_closed)
+ {
+ bitmap.Dispose();
+ return;
+ }
+ var old = PreviewImage;
+ PictureSize = new Avalonia.PixelSize(width, height);
+ PreviewImage = bitmap;
+ old?.Dispose();
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ _log?.LogInformation(ex, "Could not open {File} to annotate", CurrentFileName);
+ AddProblem(index, ex is NotSupportedException ? "error.unsupported_source" : "error.conversion_failed");
+ IsBusy = false;
+ await LoadFileAsync(index + 1);
+ return;
+ }
+ IsBusy = false;
+ }
+
+ ///
+ /// Only with a mark on the picture, as "Pixelate / blur" only with a part: Skip moves on without one.
+ ///
+ private bool CanSave() => !IsBusy && PreviewImage is not null && Marks.Marks.Any(AnnotateTool.IsDrawable);
+
+ ///
+ /// Starts the conversion that writes the picture with its marks (shown in the toast), keeps the colour and width,
+ /// and opens the next picture or closes. When it can't be started, the picture stays with its marks, to save again
+ /// or skip.
+ ///
+ [RelayCommand(CanExecute = nameof(CanSave))]
+ private async Task SaveAsync()
+ {
+ var path = _files[CurrentIndex];
+ Remember();
+ 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);
+ AddProblem(CurrentIndex, "error.conversion_failed");
+ return;
+ }
+ _outcomes[CurrentIndex] = CropItemState.Done;
+ ClearProblems(CurrentIndex);
+ await LoadFileAsync(CurrentIndex + 1);
+ }
+
+ [RelayCommand]
+ private async Task SkipAsync()
+ {
+ if (IsBusy || IsFinished)
+ return;
+ _outcomes[CurrentIndex] ??= CropItemState.Skipped;
+ await LoadFileAsync(CurrentIndex + 1);
+ }
+
+ private void AddProblem(int index, string key)
+ {
+ _outcomes[index] = CropItemState.Failed;
+ var line = $"{Path.GetFileName(_files[index])}: {_loc[key]}";
+ Problems.Add(line);
+ _problemFiles.Add(index);
+ StatusNotice = line;
+ }
+
+ /// A picture saved after all: what went wrong with it before is no failure any more.
+ private void ClearProblems(int index)
+ {
+ for (var i = _problemFiles.Count - 1; i >= 0; i--)
+ {
+ if (_problemFiles[i] != index)
+ continue;
+ _problemFiles.RemoveAt(i);
+ Problems.RemoveAt(i);
+ }
+ StatusNotice = Problems.LastOrDefault() ?? "";
+ }
+
+ /// The picture each of is about.
+ private readonly List _problemFiles = [];
+
+ ///
+ /// Closes after the last picture, or stays open on the summary when a picture could not be opened or saved.
+ ///
+ private void Finish()
+ {
+ if (Problems.Count == 0)
+ {
+ Close();
+ return;
+ }
+ Remember();
+ FailedCount = _outcomes.Count(o => o == CropItemState.Failed);
+ DoneCount = _outcomes.Count(o => o == CropItemState.Done);
+ SkippedCount = _outcomes.Count(o => o == CropItemState.Skipped);
+ Summary = _loc.Format("toast.done_errors", FailedCount, _files.Count);
+ StatusNotice = "";
+ HasMultipleFiles = false;
+ IsBusy = false;
+ Marks.Reset();
+ var old = PreviewImage;
+ PreviewImage = null;
+ old?.Dispose();
+ IsFinished = true;
+ }
+
+ [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");
+ }
+ }
+
+ private void OnLanguageChanged(object? sender, EventArgs e)
+ {
+ if (CurrentIndex < _files.Count)
+ CounterText = _loc.Format("crop.page_counter", CurrentIndex + 1, _files.Count);
+ OnPropertyChanged(nameof(SaveText));
+ foreach (var swatch in Swatches)
+ swatch.Name = _loc[swatch.NameKey];
+ }
+
+ public void Dispose()
+ {
+ // Closed with the title bar or Alt+F4 too: the colour and width are kept for next time.
+ if (!_closed && PreviewImage is not null)
+ Remember();
+ _closed = true;
+ _loc.LanguageChanged -= OnLanguageChanged;
+ Marks.Changed -= OnMarksChanged;
+ PreviewImage?.Dispose();
+ PreviewImage = null;
+ }
+}
+
+/// A colour in the bar, named for screen readers and its tooltip.
+public sealed partial class AnnotateSwatch(string color, string nameKey, AnnotateViewModel owner) : ObservableObject
+{
+ public string Color { get; } = color;
+ public string NameKey { get; } = nameKey;
+
+ [ObservableProperty] private string _name = "";
+ 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..d5819e2f6
--- /dev/null
+++ b/src/Filee.App/Views/AnnotateWindow.axaml
@@ -0,0 +1,248 @@
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Filee.App/Views/AnnotateWindow.axaml.cs b/src/Filee.App/Views/AnnotateWindow.axaml.cs
new file mode 100644
index 000000000..7af68c7e7
--- /dev/null
+++ b/src/Filee.App/Views/AnnotateWindow.axaml.cs
@@ -0,0 +1,317 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.VisualTree;
+using Filee.App.Controls;
+using Filee.App.ViewModels;
+using Filee.Core.Presets;
+using Filee.Engines.Annotations;
+
+namespace Filee.App.Views;
+
+///
+/// The "Annotate" window (): the keys, and the box text is typed in, over the picture
+/// where the tag goes.
+///
+public partial class AnnotateWindow : Window
+{
+ private AnnotationCanvas.TextRequest? _typing;
+ private double _tagMiddle;
+
+ /// The focus came from the keyboard (Tab, arrows), not from a click.
+ private bool _focusByKeyboard;
+
+ public AnnotateWindow()
+ {
+ InitializeComponent();
+ Canvas.TextRequested += (_, request) => StartTyping(request);
+ // The picture moves and scales with the window: the box being typed in goes with it.
+ Canvas.SizeChanged += (_, _) =>
+ {
+ if (_typing is not { } typing)
+ return;
+ var moved = Canvas.RequestFor(typing.Index, typing.At);
+ _typing = moved;
+ Place(moved);
+ };
+ TextEditor.LostFocus += (_, _) => FinishTyping(keep: true);
+ // The tag follows the box's width; the box stays centred on the tag.
+ TextTag.SizeChanged += (_, e) =>
+ {
+ TagFill.Width = e.NewSize.Width;
+ Avalonia.Controls.Canvas.SetTop(TextTag, _tagMiddle - e.NewSize.Height / 2);
+ };
+ // Right to left when its first letter is (Arabic, Hebrew), as the tag will be drawn.
+ TextEditor.TextChanged += (_, _) => TextEditor.FlowDirection =
+ AnnotationPainter.IsRightToLeft(TextEditor.Text ?? "") ? FlowDirection.RightToLeft : FlowDirection.LeftToRight;
+ TextEditor.KeyDown += (_, e) =>
+ {
+ if (e.Key == Key.Enter)
+ {
+ FinishTyping(keep: true);
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Escape)
+ {
+ FinishTyping(keep: false);
+ e.Handled = true;
+ }
+ };
+ AddHandler(GotFocusEvent, (_, e) => _focusByKeyboard = e.NavigationMethod is NavigationMethod.Tab or NavigationMethod.Directional,
+ Avalonia.Interactivity.RoutingStrategies.Bubble, handledEventsToo: true);
+ AddHandler(KeyDownEvent, OnKeyDownFirst, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ // Enter is the window's own (save), so a button that was clicked can't be pressed by it later; nor by its
+ // key-up (as in Pixelate / blur).
+ AddHandler(KeyUpEvent, (_, e) =>
+ {
+ if (e.Key is Key.Enter or Key.Space && !TextEditor.IsVisible && !ControlTakes(e))
+ e.Handled = true;
+ }, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ DataContextChanged += (_, _) =>
+ {
+ if (DataContext is not AnnotateViewModel vm)
+ return;
+ vm.CloseRequested += Close;
+ Closed += (_, _) => vm.Dispose();
+ };
+ }
+
+ private AnnotateViewModel? ViewModel => DataContext as AnnotateViewModel;
+
+ internal TextBox Editor => TextEditor;
+
+ /// The coloured tag shown while text is typed.
+ internal Control TypingTag => TagFill;
+ internal AnnotationCanvas AnnotationCanvas => Canvas;
+
+ ///
+ /// Shows the tag over its place as the file will get it (AnnotationPainter.DrawTag): the mark's colour, rounded, as
+ /// wide as the text from an empty tag's size, the text bold at its size and inset, text and caret in its ink; with
+ /// its text when typed again, the mark itself hidden meanwhile. The coloured tag (TagFill) is the tag's own height;
+ /// the box over it is as high as its line needs and centred on the tag, as the painter centres the line: a script
+ /// whose font reaches further (Arabic) grew the tag, or was cut off in it.
+ ///
+ private void StartTyping(AnnotationCanvas.TextRequest request)
+ {
+ if (ViewModel is not { } vm)
+ return;
+ // A click elsewhere with the Text tool keeps what was typed: the picture takes no focus from the box. Finishing
+ // can remove a mark (a text cleared), so the mark asked for is found again by itself, not by its place.
+ if (_typing is not null)
+ {
+ var asked = request.Index >= 0 && request.Index < vm.Marks.Count ? vm.Marks.Marks[request.Index] : null;
+ FinishTyping(keep: true);
+ if (asked is not null)
+ {
+ var index = vm.Marks.Marks.ToList().IndexOf(asked);
+ if (index < 0)
+ return;
+ request = request with { Index = index };
+ }
+ }
+ _typing = request;
+ var mark = request.Index >= 0 ? vm.Marks.Marks[request.Index] : null;
+ var color = Color.Parse(mark?.Color ?? vm.Color);
+ // The tag's own ink (white on dark, near-black on light), as the painter picks it.
+ var inkOn = AnnotationPainter.InkOn(new SkiaSharp.SKColor(color.R, color.G, color.B));
+ var ink = new SolidColorBrush(Color.FromRgb(inkOn.Red, inkOn.Green, inkOn.Blue));
+ TagFill.Background = new SolidColorBrush(color);
+ TextEditor.Text = mark?.Text ?? "";
+ TextEditor.FontWeight = FontWeight.Bold;
+ // From the left inset whichever way the text reads, as the tag draws it: right to left text set to the right of
+ // a box a pixel wider than the tag (rounded to whole pixels) sat that pixel off.
+ TextEditor.TextAlignment = TextAlignment.Left;
+ TextEditor.Foreground = ink;
+ TextEditor.CaretBrush = ink;
+ Place(request);
+ Canvas.TypingIndex = request.Index;
+ TextEditor.IsVisible = true;
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ TextEditor.Focus();
+ TextEditor.SelectAll();
+ });
+ }
+
+ /// The tag and its box at the request's place and size on screen.
+ private void Place(AnnotationCanvas.TextRequest request)
+ {
+ var size = request.FontSize;
+ TagFill.CornerRadius = new CornerRadius(size * 0.3);
+ TagFill.Height = request.Screen.Height;
+ TextTag.MinWidth = request.Screen.Width;
+ TextTag.MinHeight = request.Screen.Height;
+ _tagMiddle = request.Screen.Center.Y;
+ TextEditor.FontSize = size;
+ TextEditor.Padding = new Thickness(size * 0.45, 0);
+ Avalonia.Controls.Canvas.SetLeft(TagFill, request.Screen.X);
+ Avalonia.Controls.Canvas.SetTop(TagFill, request.Screen.Y);
+ Avalonia.Controls.Canvas.SetLeft(TextTag, request.Screen.X);
+ Avalonia.Controls.Canvas.SetTop(TextTag, _tagMiddle - TextTag.Bounds.Height / 2);
+ }
+
+ /// Puts the typed text on the picture (or into the mark typed again), or drops it.
+ internal void FinishTyping(bool keep)
+ {
+ if (_typing is not { } request || ViewModel is not { } vm)
+ return;
+ _typing = null;
+ TextEditor.IsVisible = false;
+ Canvas.TypingIndex = -1;
+ var text = TextEditor.Text ?? "";
+ if (!keep)
+ return;
+ if (request.Index >= 0)
+ vm.EditText(request.Index, text);
+ else
+ vm.AddText(request.At, text);
+ }
+
+ ///
+ /// The control that has the focus, reached with the keyboard, takes 's key as it would anywhere
+ /// else: Space and Enter press a button, Space chooses a tool, colour or width.
+ ///
+ private bool ControlTakes(KeyEventArgs e)
+ {
+ if (!_focusByKeyboard || e.KeyModifiers != KeyModifiers.None)
+ return false;
+ return FocusManager?.GetFocusedElement() switch
+ {
+ // Space chooses a tool, colour or width; Enter still saves.
+ RadioButton => e.Key == Key.Space,
+ Button => e.Key is Key.Space or Key.Enter,
+ _ => false,
+ };
+ }
+
+ /// A control in the bar has the focus from the keyboard.
+ private bool InBar => _focusByKeyboard && FocusManager?.GetFocusedElement() is Control control && control != this && control != TextEditor;
+
+ ///
+ /// Before the focused control: Enter saves and Esc closes (from the bar, Esc goes back to the picture). The marks
+ /// come first in the Tab order: Tab and Shift+Tab go from mark to mark (with Select), and past the last one on into
+ /// the bar. The arrows move the picked mark a pixel of the picture, ten with Shift; the picture is never mirrored,
+ /// so Left is always left.
+ ///
+ private void OnKeyDownFirst(object? sender, KeyEventArgs e)
+ {
+ if (ViewModel is not { } vm || TextEditor.IsVisible || ControlTakes(e))
+ return;
+ var shiftOnly = (e.KeyModifiers & ~KeyModifiers.Shift) == KeyModifiers.None;
+ switch (e.Key)
+ {
+ case Key.Escape when InBar:
+ FocusManager?.Focus(null);
+ _focusByKeyboard = false;
+ e.Handled = true;
+ break;
+ case Key.Left or Key.Right or Key.Up or Key.Down when InBar && e.KeyModifiers == KeyModifiers.None
+ && FocusManager?.GetFocusedElement() is RadioButton { GroupName: { } group } choice:
+ ChooseAlong(choice, group, e.Key);
+ e.Handled = true;
+ break;
+ case Key.Escape:
+ vm.CloseCommand.Execute(null);
+ e.Handled = true;
+ break;
+ case Key.Enter when vm.IsFinished:
+ vm.CloseCommand.Execute(null);
+ e.Handled = true;
+ break;
+ case Key.Enter when e.KeyModifiers == KeyModifiers.None:
+ if (vm.SaveCommand.CanExecute(null))
+ vm.SaveCommand.Execute(null);
+ e.Handled = true;
+ break;
+ case Key.Tab when shiftOnly && !InBar && vm.Marks.Count > 0 && !vm.IsBusy:
+ var back = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
+ // Past the last mark (or before the first), on into the bar: its first tool (or Save).
+ if (vm.Marks.Selected == (back ? 0 : vm.Marks.Count - 1))
+ {
+ vm.Marks.Select(-1);
+ Control into = back ? SaveButton.IsEffectivelyEnabled ? SaveButton : SelectTool : SelectTool;
+ into.Focus(NavigationMethod.Tab);
+ e.Handled = true;
+ break;
+ }
+ vm.Mode = AnnotateMode.Select;
+ vm.Marks.SelectNext(back ? -1 : 1);
+ e.Handled = true;
+ break;
+ // Not while a control in the bar has the keyboard's focus: the arrows are about it there.
+ case Key.Left or Key.Right or Key.Up or Key.Down when vm.HasSelection && shiftOnly && !vm.IsBusy && !InBar:
+ var step = e.KeyModifiers.HasFlag(KeyModifiers.Shift) ? 10 : 1;
+ vm.Marks.Nudge(e.Key switch { Key.Left => -step, Key.Right => step, _ => 0 },
+ e.Key switch { Key.Up => -step, Key.Down => step, _ => 0 }, vm.PictureSize.Width, vm.PictureSize.Height);
+ e.Handled = true;
+ break;
+ }
+ }
+
+ ///
+ /// The arrows go along a row of choices (tools, colours, widths) and choose, as in a group of radio buttons: Right
+ /// and Down to the next, Left and Up to the one before, round and round; Left is left in a right-to-left UI too.
+ ///
+ private void ChooseAlong(RadioButton choice, string group, Key key)
+ {
+ var row = this.GetVisualDescendants().OfType().Where(r => r.GroupName == group && r.IsEffectivelyEnabled).ToList();
+ var at = row.IndexOf(choice);
+ if (at < 0 || row.Count < 2)
+ return;
+ var step = key is Key.Right or Key.Down ? 1 : -1;
+ if (key is Key.Left or Key.Right && choice.FlowDirection == FlowDirection.RightToLeft)
+ step = -step;
+ var next = row[((at + step) % row.Count + row.Count) % row.Count];
+ next.Focus(NavigationMethod.Directional);
+ if (next.Command?.CanExecute(next.CommandParameter) == true)
+ next.Command.Execute(next.CommandParameter);
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (e.Handled || ViewModel is not { } vm || TextEditor.IsVisible)
+ return;
+ var command = e.KeyModifiers.HasFlag(KeyModifiers.Control) || e.KeyModifiers.HasFlag(KeyModifiers.Meta);
+ var plain = e.KeyModifiers == KeyModifiers.None;
+ e.Handled = true;
+ switch (e.Key)
+ {
+ case Key.Delete or Key.Back when plain:
+ vm.RemoveSelectedCommand.Execute(null);
+ break;
+ case Key.Z when command && e.KeyModifiers.HasFlag(KeyModifiers.Shift):
+ case Key.Y when command:
+ vm.RedoCommand.Execute(null);
+ break;
+ case Key.Z when command:
+ vm.UndoCommand.Execute(null);
+ break;
+ case Key.V when plain:
+ vm.Mode = AnnotateMode.Select;
+ break;
+ case Key.P when plain:
+ vm.Mode = AnnotateMode.Pen;
+ break;
+ case Key.A when plain:
+ vm.Mode = AnnotateMode.Arrow;
+ break;
+ case Key.B when plain:
+ vm.Mode = AnnotateMode.Box;
+ break;
+ case Key.H when plain:
+ vm.Mode = AnnotateMode.Highlight;
+ break;
+ case Key.T when plain:
+ vm.Mode = AnnotateMode.Text;
+ break;
+ case Key.N when plain:
+ vm.Mode = AnnotateMode.Number;
+ break;
+ default:
+ e.Handled = false;
+ break;
+ }
+ }
+}
diff --git a/src/Filee.Core/Conversion/JobQueue.cs b/src/Filee.Core/Conversion/JobQueue.cs
index e45cb052a..b4ea1eb24 100644
--- a/src/Filee.Core/Conversion/JobQueue.cs
+++ b/src/Filee.Core/Conversion/JobQueue.cs
@@ -581,11 +581,19 @@ private void FailFile(ConversionJob job, FileResult file, string key, string? de
Notify(job);
}
- /// A file that would be used as it is although the preset turns, mirrors or covers parts of it.
+ ///
+ /// Why a file the preset turns, mirrors, covers parts of or draws on can't be: no step of its route changes images.
+ ///
+ private static string CannotReworkKey(ImageOptions image) =>
+ image.HidesAreas ? "error.cannot_hide" : image.HasAnnotations ? "error.images_only" : "error.cannot_turn";
+
+ ///
+ /// A file that would be used as it is although the preset turns, mirrors, covers parts of or draws on it.
+ ///
private void FailUnturned(ConversionJob job, FileResult file, string detail)
{
file.State = FileState.Failed;
- file.ErrorKey = job.Preset.Image.HidesAreas ? "error.cannot_hide" : "error.cannot_turn";
+ file.ErrorKey = CannotReworkKey(job.Preset.Image);
file.ErrorDetail = detail;
file.Progress = 1;
Notify(job);
@@ -812,7 +820,7 @@ private async Task> RunFileAsync(
// Parts of a picture to hide likewise, once: a route that can't cover them must not write the picture uncovered.
var turnAt = runWith.Image.ReworksPixels ? route.FirstTransformStep() : -1;
if (runWith.Image.ReworksPixels && turnAt < 0)
- return Fail(file, runWith.Image.HidesAreas ? "error.cannot_hide" : "error.cannot_turn", $"{source.Id} → {target}");
+ return Fail(file, CannotReworkKey(runWith.Image), $"{source.Id} → {target}");
var unturned = turnAt < 0 ? runWith : Unturned(runWith);
file.State = FileState.Running;
@@ -952,6 +960,7 @@ static Preset Unturned(Preset preset)
copy.Image.Rotation = ImageRotation.None;
copy.Image.Flip = ImageFlip.None;
copy.Image.HiddenAreas = null;
+ copy.Image.Annotations = null;
return copy;
}
diff --git a/src/Filee.Core/History/HistoryEntry.cs b/src/Filee.Core/History/HistoryEntry.cs
index 3fa418fa7..b93e95d15 100644
--- a/src/Filee.Core/History/HistoryEntry.cs
+++ b/src/Filee.Core/History/HistoryEntry.cs
@@ -66,9 +66,12 @@ public static HistoryEntry From(ConversionJob job)
Sources = [.. job.Sources],
Outputs = [.. job.Outputs],
Failures = failures,
- // history.json isn't the place for a PDF password: a retry takes it from the preset again.
- // No Retry for parts of a picture to hide: a Filee from before them would write the picture as it is.
- Preset = failures.Count > 0 && !job.Preset.Image.HidesAreas ? job.Preset.Clone().WithoutPassword(mark: true) : null,
+ // history.json isn't the place for a PDF password: a retry takes it from the preset again. No Retry for
+ // parts of a picture to hide or marks drawn on it: a Filee from before them would write the picture as it
+ // is.
+ Preset = failures.Count > 0 && !job.Preset.Image.HidesAreas && !job.Preset.Image.HasAnnotations
+ ? job.Preset.Clone().WithoutPassword(mark: true)
+ : null,
Undoable = UndoableOutputs(job),
OriginalsInTrash = job.Files.Any(f => f.OriginalInTrash),
};
diff --git a/src/Filee.Core/Presets/AnnotateTool.cs b/src/Filee.Core/Presets/AnnotateTool.cs
new file mode 100644
index 000000000..1eebd02e8
--- /dev/null
+++ b/src/Filee.Core/Presets/AnnotateTool.cs
@@ -0,0 +1,54 @@
+// "Annotate" runs as an ordinary conversion once its window has the marks: the tool preset becomes a conversion into
+// the picture's own format with the marks (ImageOptions.Annotations), so the job queue, the toast, cancelling and the
+// output rules work as for any other file. The colour and width picked last are kept in the tool.
+
+using Filee.Core.Conversion;
+
+namespace Filee.Core.Presets;
+
+public static class AnnotateTool
+{
+ /// The colours the window offers, in order (the first is the one it starts with).
+ public static readonly string[] Colors = ["#E5484D", "#F5A524", "#F9D84A", "#30A46C", "#3E63DD", "#1C1C1C"];
+
+ /// The names of , in order: what a screen reader says for each.
+ public static readonly string[] ColorNameKeys = ["annotate.red", "annotate.orange", "annotate.yellow", "annotate.green", "annotate.blue", "annotate.black"];
+
+ /// Marks too small to mean anything (a click, not a drag), in parts of the picture's side.
+ public const double MinSize = 0.004;
+
+ ///
+ /// The format a picture is written in: its own when it can be written, else PNG (as Pixelate / blur).
+ ///
+ public static string TargetFor(string sourceFormat, RoutePlanner planner) => HideTool.TargetFor(sourceFormat, planner);
+
+ /// Writes a picture as with on it.
+ public static Preset ConversionOf(Preset tool, string targetFormat, IEnumerable marks)
+ {
+ var preset = tool.Clone();
+ preset.Kind = PresetKind.Convert;
+ preset.Tool = null;
+ preset.SubPresetIds = [];
+ preset.TargetFormat = targetFormat;
+ preset.Image.Annotations = [.. marks.Where(IsDrawable).Select(mark => mark.Clone())];
+ preset.Output.OnlyIfSmaller = false;
+ return preset;
+ }
+
+ /// A mark with what it needs to be drawn: points for its kind, and text for a text.
+ public static bool IsDrawable(Annotation mark) => mark.Kind switch
+ {
+ AnnotationKind.Pen => mark.Points.Count >= 2,
+ AnnotationKind.Arrow or AnnotationKind.Box or AnnotationKind.Highlight => mark.Points.Count >= 2,
+ AnnotationKind.Text => mark.Points.Count >= 1 && !string.IsNullOrWhiteSpace(mark.Text),
+ AnnotationKind.Number => mark.Points.Count >= 1,
+ _ => false,
+ };
+
+ /// Keeps the colour and width picked last in .
+ public static void Remember(Preset tool, string color, AnnotationWidth width)
+ {
+ tool.Image.AnnotationColor = color;
+ tool.Image.AnnotationWidth = width;
+ }
+}
diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs
index 75f06388e..e046cb6c2 100644
--- a/src/Filee.Core/Presets/BuiltInData.cs
+++ b/src/Filee.Core/Presets/BuiltInData.cs
@@ -59,6 +59,7 @@ .. TrimPresets(),
},
.. ImageEditPresets(),
.. HidePresets(),
+ .. AnnotatePresets(),
new()
{
Id = "tool-sheet-studio", NameKey = "preset.sheet_studio", TargetFormat = "pdf",
@@ -260,6 +261,22 @@ public static List HidePresets() =>
},
];
+ /// Id of the built-in "Annotate" tool (settings schema 60): in the palette, on no donut.
+ public const string AnnotateId = "tool-annotate";
+
+ ///
+ /// "Annotate" (settings schema 60): pen, arrows, boxes, highlights, text and numbers drawn on pictures, saved as
+ /// "{name} Annotated". In the palette only: the picture donuts are full.
+ ///
+ public static List AnnotatePresets() =>
+ [
+ new()
+ {
+ Id = AnnotateId, NameKey = "preset.annotate", TargetFormat = SameAsSource,
+ Kind = PresetKind.Tool, Tool = Preset.AnnotateTool, Output = { FileNamePattern = "{name} Annotated" },
+ },
+ ];
+
///
/// "Trim transparent borders" (issue #332, added in settings schema 33): a copy cropped to the visible pixels, in the
/// same format. On the images donut's back face after "Remove background".
diff --git a/src/Filee.Core/Presets/Preset.cs b/src/Filee.Core/Presets/Preset.cs
index 8cf7b1bda..72005e12a 100644
--- a/src/Filee.Core/Presets/Preset.cs
+++ b/src/Filee.Core/Presets/Preset.cs
@@ -57,6 +57,14 @@ public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOpt
writer.WriteStringValue(value.ToString());
}
+///
+/// An unknown mark kind (a newer Filee's) is read as : never drawn.
+///
+public sealed class AnnotationKindJsonConverter : LenientEnumJsonConverter
+{
+ protected override AnnotationKind Unknown => AnnotationKind.Unknown;
+}
+
/// An unknown kind is read as a group: never run as a conversion.
public sealed class PresetKindJsonConverter : LenientEnumJsonConverter
{
@@ -259,9 +267,16 @@ public bool RecognizesTextOf(string sourceFormat) =>
public const string HideTool = "hide";
///
- /// : which window it opens, (or null, as saved before there were
- /// other tools), , or . A tool this version
- /// doesn't know (from a newer Filee) opens nothing. Ignored for other kinds.
+ /// of "Annotate" (settings schema 60): pen, arrows, boxes, highlights, text and numbers drawn on
+ /// a picture ().
+ ///
+ public const string AnnotateTool = "annotate";
+
+ ///
+ /// : which window it opens, (or null, as saved before there
+ /// were other tools), , , or
+ /// . A tool this version doesn't know (from a newer Filee) opens nothing. Ignored for
+ /// other kinds.
///
public string? Tool { get; set; }
@@ -281,6 +296,10 @@ public bool RecognizesTextOf(string sourceFormat) =>
[JsonIgnore]
public bool IsHideTool => IsTool && Tool == HideTool;
+ /// True for "Annotate".
+ [JsonIgnore]
+ public bool IsAnnotateTool => IsTool && Tool == AnnotateTool;
+
///
/// True if converting a file cuts a clip of it (,
/// set by the clip window on the conversion it starts): the file is a video. Other files convert as usual.
@@ -444,6 +463,83 @@ public sealed class HiddenArea
public HiddenArea Clone() => (HiddenArea)MemberwiseClone();
}
+/// What an "Annotate" mark is ().
+public enum AnnotationKind
+{
+ /// A kind from a newer Filee: not drawn, rather than drawn as something it isn't.
+ Unknown = -1,
+ /// A freehand line through .
+ Pen,
+ /// An arrow from its first point (the tail) to its second (the tip).
+ Arrow,
+ /// A frame from one corner to the other.
+ Box,
+ /// A see-through marker from one corner to the other.
+ Highlight,
+ /// on a tag in the colour, at its point.
+ Text,
+ /// in a circle in the colour, at its point.
+ Number,
+}
+
+/// How thick a mark is drawn: lines, text and numbers alike.
+public enum AnnotationWidth
+{
+ Thin,
+ Medium,
+ Thick,
+}
+
+/// A point on the picture as it is seen, in parts of it: 0 to 1 from its left and top edges.
+public sealed class AnnotationPoint
+{
+ public double X { get; set; }
+ public double Y { get; set; }
+
+ public AnnotationPoint Clone() => (AnnotationPoint)MemberwiseClone();
+}
+
+///
+/// A mark "Annotate" draws on a picture. Its points are parts of the picture, and its sizes parts of the picture's
+/// shorter side, so a mark looks the same at any size.
+///
+public sealed class Annotation
+{
+ [JsonConverter(typeof(AnnotationKindJsonConverter))]
+ public AnnotationKind Kind { get; set; }
+
+ /// "#RRGGBB".
+ public string Color { get; set; } = "#E5484D";
+
+ [JsonConverter(typeof(LenientEnumJsonConverter))]
+ public AnnotationWidth Width { get; set; } = AnnotationWidth.Medium;
+
+ ///
+ /// 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 => _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; }
+
+ /// The number a shows.
+ public int Number { get; set; }
+
+ public Annotation Clone()
+ {
+ var copy = (Annotation)MemberwiseClone();
+ copy.Points = [.. Points.Select(p => p.Clone())];
+ return copy;
+ }
+}
+
/// Raster image encoding options.
public sealed class ImageOptions
{
@@ -488,7 +584,28 @@ public sealed class ImageOptions
/// engine applies image options (); a file is never passed on as it is.
///
[JsonIgnore]
- public bool ReworksPixels => TurnsOrMirrors || HidesAreas;
+ public bool ReworksPixels => TurnsOrMirrors || HidesAreas || HasAnnotations;
+
+ /// "Annotate": the marks drawn on the picture as it is seen; null for every other preset.
+ public List? Annotations
+ {
+ get => _annotations;
+ // Read leniently: an empty place in the list (a preset edited by hand) is no mark.
+ set => _annotations = value?.Where(mark => mark is not null).ToList();
+ }
+
+ private List? _annotations;
+
+ /// The picture gets marks drawn on it ().
+ [JsonIgnore]
+ public bool HasAnnotations => Annotations is { Count: > 0 };
+
+ /// "Annotate": the colour picked last, "#RRGGBB", kept in the tool for the next time.
+ public string? AnnotationColor { get; set; }
+
+ /// "Annotate": the width picked last, kept in the tool for the next time.
+ [JsonConverter(typeof(LenientEnumJsonConverter))]
+ public AnnotationWidth AnnotationWidth { get; set; } = AnnotationWidth.Medium;
/// Whether the preset edits picture content, size, or playback speed.
[JsonIgnore]
@@ -691,6 +808,7 @@ public ImageOptions Clone()
var copy = (ImageOptions)MemberwiseClone();
copy.IcoSizes = [.. IcoSizes];
copy.HiddenAreas = HiddenAreas?.Select(area => area.Clone()).ToList();
+ copy.Annotations = Annotations?.Select(mark => mark.Clone()).ToList();
copy.CustomSize = CustomSize is { } size
? new CustomImageSize { Width = size.Width, Height = size.Height, LockAspect = size.LockAspect, Unit = size.Unit, Dpi = size.Dpi }
: null;
diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs
index 78af45b19..4e8e564a8 100644
--- a/src/Filee.Core/Settings/AppSettings.cs
+++ b/src/Filee.Core/Settings/AppSettings.cs
@@ -123,7 +123,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest
public sealed class AppSettings
{
/// Current schema version. Increase when the format changes and add a migration.
- public const int CurrentSchemaVersion = 59;
+ public const int CurrentSchemaVersion = 60;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index 4070806cf..fd929e014 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -966,6 +966,14 @@ public static bool ApplyToLibrary(int from, List presets, List p.Id != BuiltInData.AnnotateId))
+ {
+ 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
new file mode 100644
index 000000000..d676ec4fb
--- /dev/null
+++ b/src/Filee.Engines/Annotations/AnnotationPainter.cs
@@ -0,0 +1,252 @@
+// Draws "Annotate" marks with SkiaSharp: the window draws its live preview with it (through Avalonia's Skia canvas) and
+// 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.Globalization;
+using Filee.Core.Presets;
+using SkiaSharp;
+
+namespace Filee.Engines.Annotations;
+
+public static class AnnotationPainter
+{
+ /// Line thickness by width, in parts of the picture's shorter side.
+ private static readonly double[] StrokeParts = [0.004, 0.007, 0.011];
+
+ /// Text size by width.
+ private static readonly double[] FontParts = [0.028, 0.036, 0.048];
+
+ /// A number's circle by width.
+ private static readonly double[] NumberParts = [0.04, 0.05, 0.065];
+
+ /// How see-through a highlight is: its colour at this opacity.
+ internal const byte HighlightAlpha = 102;
+
+ public static float StrokeOf(AnnotationWidth width, float pictureWidth, float pictureHeight) =>
+ (float)Math.Max(2, Math.Min(pictureWidth, pictureHeight) * StrokeParts[Index(width)]);
+
+ public static float FontSizeOf(AnnotationWidth width, float pictureWidth, float pictureHeight) =>
+ (float)Math.Max(10, Math.Min(pictureWidth, pictureHeight) * FontParts[Index(width)]);
+
+ public static float NumberSizeOf(AnnotationWidth width, float pictureWidth, float pictureHeight) =>
+ (float)Math.Max(14, Math.Min(pictureWidth, pictureHeight) * NumberParts[Index(width)]);
+
+ private static int Index(AnnotationWidth width) => Math.Clamp((int)width, 0, 2);
+
+ /// A mark's point in pixels of a × picture.
+ public static SKPoint At(AnnotationPoint point, float width, float height) => new((float)(point.X * width), (float)(point.Y * height));
+
+ ///
+ /// Draws , in order (the last on top), on a ×
+ /// picture.
+ ///
+ public static void Paint(SKCanvas canvas, IEnumerable marks, float width, float height)
+ {
+ foreach (var mark in marks)
+ Paint(canvas, mark, width, height);
+ }
+
+ public static void Paint(SKCanvas canvas, Annotation mark, float width, float height)
+ {
+ if (mark.Points.Count == 0)
+ return;
+ var color = ColorOf(mark.Color);
+ var stroke = StrokeOf(mark.Width, width, height);
+ switch (mark.Kind)
+ {
+ case AnnotationKind.Pen when mark.Points.Count >= 2:
+ {
+ using var path = new SKPath();
+ path.MoveTo(At(mark.Points[0], width, height));
+ foreach (var point in mark.Points.Skip(1))
+ path.LineTo(At(point, width, height));
+ using var paint = Line(color, stroke);
+ canvas.DrawPath(path, paint);
+ break;
+ }
+ case AnnotationKind.Arrow when mark.Points.Count >= 2:
+ DrawArrow(canvas, At(mark.Points[0], width, height), At(mark.Points[1], width, height), color, stroke);
+ break;
+ case AnnotationKind.Box when mark.Points.Count >= 2:
+ {
+ var rect = RectOf(mark, width, height);
+ using var paint = Line(color, stroke);
+ canvas.DrawRoundRect(rect, stroke * 1.2f, stroke * 1.2f, paint);
+ break;
+ }
+ case AnnotationKind.Highlight when mark.Points.Count >= 2:
+ {
+ using var paint = new SKPaint { Color = color.WithAlpha(HighlightAlpha), IsAntialias = true, Style = SKPaintStyle.Fill };
+ canvas.DrawRect(RectOf(mark, width, height), paint);
+ break;
+ }
+ case AnnotationKind.Text when !string.IsNullOrEmpty(mark.Text):
+ DrawTag(canvas, mark, width, height, color);
+ break;
+ case AnnotationKind.Number:
+ DrawNumber(canvas, mark, width, height, color);
+ break;
+ }
+ }
+
+ /// The rectangle between a box's or highlight's two corners, in pixels.
+ public static SKRect RectOf(Annotation mark, float width, float height)
+ {
+ var a = At(mark.Points[0], width, height);
+ var b = At(mark.Points[1], width, height);
+ return new SKRect(Math.Min(a.X, b.X), Math.Min(a.Y, b.Y), Math.Max(a.X, b.X), Math.Max(a.Y, b.Y));
+ }
+
+ ///
+ /// The tag a text sits on, in pixels: its point is the tag's top left corner. Also what selecting and moving a text
+ /// go by.
+ ///
+ public static SKRect TagOf(Annotation mark, float width, float height)
+ {
+ 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);
+ }
+
+ /// A number's circle, in pixels: its point is the centre.
+ public static SKRect CircleOf(Annotation mark, float width, float height)
+ {
+ var radius = NumberSizeOf(mark.Width, width, height) / 2;
+ var at = At(mark.Points[0], width, 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;
+ var dy = tip.Y - tail.Y;
+ var length = MathF.Sqrt(dx * dx + dy * dy);
+ if (length < 0.5f)
+ return;
+ var (ux, uy) = (dx / length, dy / length);
+ // The head: as long as four and a half lines are thick, but never more than half the arrow.
+ var headLength = Math.Min(stroke * 4.5f, length * 0.5f);
+ var headHalf = headLength * 0.58f;
+ var neck = new SKPoint(tip.X - ux * headLength, tip.Y - uy * headLength);
+ using (var paint = Line(color, stroke))
+ canvas.DrawLine(tail, new SKPoint(neck.X + ux * stroke * 0.5f, neck.Y + uy * stroke * 0.5f), paint);
+ using var head = new SKPath();
+ head.MoveTo(tip);
+ head.LineTo(neck.X - uy * headHalf, neck.Y + ux * headHalf);
+ head.LineTo(neck.X + uy * headHalf, neck.Y - ux * headHalf);
+ head.Close();
+ using var fill = new SKPaint { Color = color, IsAntialias = true, Style = SKPaintStyle.Fill };
+ canvas.DrawPath(head, fill);
+ }
+
+ private static void DrawTag(SKCanvas canvas, Annotation mark, float width, float height, SKColor color)
+ {
+ var tag = TagOf(mark, width, height);
+ 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);
+ 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)
+ {
+ var circle = CircleOf(mark, width, height);
+ var radius = circle.Width / 2;
+ using (var ring = new SKPaint { Color = SKColors.White, IsAntialias = true, Style = SKPaintStyle.Fill })
+ 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(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);
+
+ ///
+ /// How a font is found for a letter the first font lacks, and whether it is made bold: as the text box labels are
+ /// typed in does it.
+ ///
+ public static void UseFallback(Func fallback) => LabelText.UseFallback(fallback);
+
+ /// 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,
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = stroke,
+ StrokeCap = SKStrokeCap.Round,
+ StrokeJoin = SKStrokeJoin.Round,
+ };
+
+ /// 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;
+
+ internal static SKColor ColorOf(string? text) =>
+ text is not null && SKColor.TryParse(text, out var color) ? color.WithAlpha(255) : new SKColor(0xE5, 0x48, 0x4D);
+}
diff --git a/src/Filee.Engines/Annotations/Annotator.cs b/src/Filee.Engines/Annotations/Annotator.cs
new file mode 100644
index 000000000..19c82d238
--- /dev/null
+++ b/src/Filee.Engines/Annotations/Annotator.cs
@@ -0,0 +1,121 @@
+// "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;
+
+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.
+ ///
+ public static void Apply(IMagickImage image, ImageOptions options)
+ {
+ if (!options.HasAnnotations)
+ return;
+ int width = (int)image.Width, height = (int)image.Height;
+ 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);
+ // 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(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
+ {
+ bitmap.PeekPixels().ReadPixels(info, pinned.AddrOfPinnedObject(), info.RowBytes);
+ }
+ finally
+ {
+ pinned.Free();
+ }
+ 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..9de67a3df
--- /dev/null
+++ b/src/Filee.Engines/Annotations/LabelText.cs
@@ -0,0 +1,541 @@
+// 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([]);
+ private static volatile Func? _fallback;
+
+ /// Faces the app's fallback found, and whether the text box makes each bold.
+ private static readonly ConcurrentDictionary FoundFaces = new(ReferenceEqualityComparer.Instance);
+
+ ///
+ /// 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 [];
+ }
+ });
+
+ ///
+ /// How a font is found for a letter the first font lacks, and whether it is made bold: as the text box does it (the
+ /// app asks Avalonia's font manager over the whole chain, as its text layout asks it), so the two pick the same
+ /// face. Walking the chain in order picked Noto Sans KR for "★" where the box has Noto Sans JP, and on Windows 11
+ /// 25H2 the system, asked without the UI's language, gave the colour Segoe UI Emoji for "👍" where the box has the
+ /// plain Segoe UI Symbol. Without it (the command line), the chain is walked and the system asked.
+ ///
+ public static void UseFallback(Func fallback)
+ {
+ _fallback = fallback;
+ Fallbacks.Clear();
+ FoundFaces.Clear();
+ }
+
+ ///
+ /// 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) =>
+ new(face, size) { Edging = SKFontEdging.Antialias, Subpixel = true, Embolden = MadeBold(face) };
+
+ ///
+ /// Whether Skia makes the face bold, as Avalonia does in the text box: a font of the chain that has no bold face
+ /// is (a system font named there); one the system finds for a letter the chain lacks never is (Avalonia's font
+ /// fallback asks for no simulation), so an emoji font's colour glyphs keep their shape.
+ ///
+ internal static bool MadeBold(SKTypeface face)
+ {
+ if (FoundFaces.TryGetValue(face, out var madeBold))
+ return madeBold;
+ if (face.IsBold)
+ return false;
+ var chain = Fonts;
+ return chain.Length > 0 ? Array.IndexOf(chain, face) >= 0 : face == SystemPrimary.Value;
+ }
+
+ ///
+ /// 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);
+ // Before the marks: the Arabic number signs (U+0600 to U+0605) are format characters, but Arabic numbers.
+ if (c is >= 0x0660 and <= 0x0669 or 0x066B or 0x066C or >= 0x0600 and <= 0x0605)
+ return Kind.AN;
+ if (category is UnicodeCategory.NonSpacingMark or UnicodeCategory.EnclosingMark or UnicodeCategory.Format)
+ return Kind.NSM;
+ if (category == UnicodeCategory.DecimalDigitNumber)
+ return Kind.EN;
+ // Signs, terminators and separators, with their small and full-width forms.
+ if (c is '+' or '-' or 0x2212 or 0xFB29 or 0xFE62 or 0xFE63 or 0xFF0B or 0xFF0D)
+ return Kind.ES;
+ if (c is '#' or '$' or '%' or 0x00B0 or 0x066A or 0x2030 or 0x2031 or >= 0x2032 and <= 0x2034 or 0xFE5F or 0xFE69 or 0xFE6A
+ or 0xFF03 or 0xFF04 or 0xFF05 || category == UnicodeCategory.CurrencySymbol)
+ return Kind.ET;
+ if (c is ',' or '.' or ':' or '/' or 0x00A0 or 0x060C or 0x202F or 0xFE50 or 0xFE52 or 0xFE55 or 0xFF0C or 0xFF0E or 0xFF0F
+ or 0xFF1A)
+ 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;
+
+ ///
+ /// With the app's fallback, as Avalonia's text layout picks it: the first font if it has the letter, else the font
+ /// before it if that has it, else the one the fallback finds. Without: 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)
+ {
+ if (_fallback is not null)
+ {
+ if (Primary.ContainsGlyph(letter))
+ return Primary;
+ return before?.ContainsGlyph(letter) == true ? before : FallbackFor(letter) ?? Primary;
+ }
+ 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 =>
+ _fallback is { } app ? Found(app(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))));
+
+ private static SKTypeface? Found((SKTypeface Face, bool MadeBold)? found)
+ {
+ if (found is not var (face, madeBold))
+ return null;
+ FoundFaces[face] = madeBold;
+ return face;
+ }
+
+ ///
+ /// 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/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs
index ee00756b8..ec4cf11fb 100644
--- a/src/Filee.Engines/Magick/ImageEncoder.cs
+++ b/src/Filee.Engines/Magick/ImageEncoder.cs
@@ -296,6 +296,8 @@ public static void ApplyOptions(IMagickImage image, ImageOptions options,
// "Pixelate / blur": the parts were drawn on the picture as it is seen, before it is turned or resized. It also
// drops the small previews of the uncovered picture a file can carry.
AreaHider.Apply(image, options);
+ // "Annotate": the marks were drawn on the picture as it is seen too, after any parts were hidden.
+ Annotations.Annotator.Apply(image, options);
// After AutoOrient, so a phone photo turns the way it looks, not the way its pixels are stored.
Turn(image, options);
diff --git a/src/Filee.Engines/Vector/GlyphOutlines.cs b/src/Filee.Engines/Vector/GlyphOutlines.cs
index eb0af0136..950eae519 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,30 @@ internal sealed class GlyphOutlines : IDisposable
private const int HarfBuzzScale = 512;
private readonly Dictionary _shapers = new(StringComparer.Ordinal);
+ // Entries go with their typeface: the table holds typefaces weakly, and a HarfBuzz font frees its native font when
+ // it is collected (NativeObject's finalizer), so typefaces left behind by a language change aren't kept.
+ 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 +66,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 +119,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
new file mode 100644
index 000000000..2ae5aacbb
--- /dev/null
+++ b/tests/Filee.App.Tests/AnnotateWindowTests.cs
@@ -0,0 +1,1152 @@
+// The "Annotate" window through the mouse and keyboard: each tool draws its mark where dragged or clicked, a click that
+// drew nothing leaves nothing, numbers count up, Select moves and resizes, colour and width restyle the selected mark,
+// text is typed in place, undo / redo / delete work, Save starts the conversion with the marks, and what the window
+// draws is what the file gets.
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Headless.XUnit;
+using Avalonia.Input;
+using Avalonia.Styling;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+using Filee.App.Controls;
+using Filee.App.Services;
+using Filee.App.ViewModels;
+using Filee.App.Views;
+using Filee.Core.Localization;
+using Filee.Core.Presets;
+using Filee.Core.Settings;
+using ImageMagick;
+
+namespace Filee.App.Tests;
+
+public sealed class AnnotateWindowTests : IDisposable
+{
+ private readonly string _folder = Directory.CreateTempSubdirectory("filee-annotate-").FullName;
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_folder, recursive: true); } catch (IOException) { }
+ }
+
+ 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"), 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)
+ .FillColor(new MagickColor("#ECECEC")).Rectangle(40, 120, 560, 134)
+ .Draw(image);
+ image.Write(path, MagickFormat.Png);
+ return path;
+ }
+
+ 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", 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 };
+ window.Show();
+ Wait(() => vm.PreviewImage is not null && !vm.IsBusy);
+ RenderTests.Pump();
+ return (window, vm, exported);
+ }
+
+ private static void Wait(Func done)
+ {
+ // Up to about 5 s, as the Pixelate / blur and Crop tests allow: a slow runner can take longer than 2.
+ for (var i = 0; i < 1000 && !done(); i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ Thread.Sleep(5);
+ }
+ Assert.True(done());
+ }
+
+ /// The window point of (x, y) in parts of the picture.
+ private static Point On(AnnotateWindow window, double x, double y)
+ {
+ var picture = window.AnnotationCanvas.CalculateImageBounds();
+ return window.AnnotationCanvas.TranslatePoint(new Point(picture.X + x * picture.Width, picture.Y + y * picture.Height), window)!.Value;
+ }
+
+ private static void Drag(AnnotateWindow window, (double X, double Y) from, (double X, double Y) to, int steps = 6)
+ {
+ window.MouseDown(On(window, from.X, from.Y), MouseButton.Left);
+ for (var i = 1; i <= steps; i++)
+ window.MouseMove(On(window, from.X + (to.X - from.X) * i / steps, from.Y + (to.Y - from.Y) * i / steps));
+ window.MouseUp(On(window, to.X, to.Y), MouseButton.Left);
+ Dispatcher.UIThread.RunJobs();
+ }
+
+ private static void Click(AnnotateWindow window, double x, double y)
+ {
+ window.MouseDown(On(window, x, y), MouseButton.Left);
+ window.MouseUp(On(window, x, y), MouseButton.Left);
+ Dispatcher.UIThread.RunJobs();
+ }
+
+ private static void Key(AnnotateWindow window, Key key, RawInputModifiers modifiers = RawInputModifiers.None) =>
+ window.KeyPress(key, modifiers, PhysicalKey.None, null);
+
+ [AvaloniaFact]
+ public void Each_drawing_tool_draws_its_mark_where_dragged_and_a_click_draws_nothing()
+ {
+ var (window, vm, _) = Open();
+ try
+ {
+ vm.Mode = AnnotateMode.Box;
+ Drag(window, (0.2, 0.3), (0.6, 0.5));
+ Click(window, 0.8, 0.8); // no size: no box
+ vm.Mode = AnnotateMode.Arrow;
+ Drag(window, (0.1, 0.9), (0.7, 0.85));
+ vm.Mode = AnnotateMode.Highlight;
+ Drag(window, (0.05, 0.22), (0.7, 0.28));
+ vm.Mode = AnnotateMode.Pen;
+ Drag(window, (0.1, 0.1), (0.4, 0.15), steps: 12);
+
+ Assert.Equal([AnnotationKind.Box, AnnotationKind.Arrow, AnnotationKind.Highlight, AnnotationKind.Pen], vm.Marks.Marks.Select(m => m.Kind));
+ var box = vm.Marks.Marks[0];
+ Assert.Equal(0.2, box.Points[0].X, 2);
+ Assert.Equal(0.5, box.Points[1].Y, 2);
+ Assert.True(vm.Marks.Marks[3].Points.Count > 5);
+ Assert.All(vm.Marks.Marks, m => Assert.Equal(AnnotateTool.Colors[0], m.Color));
+ // A colour picked after drawing goes to the next mark, not to the one just drawn.
+ Assert.Equal(-1, vm.Marks.Selected);
+ vm.Swatches[2].ChooseCommand.Execute(null);
+ Assert.Equal(AnnotateTool.Colors[0], vm.Marks.Marks[^1].Color);
+ // One undo step each.
+ vm.UndoCommand.Execute(null);
+ Assert.Equal(3, vm.Marks.Count);
+ vm.RedoCommand.Execute(null);
+ Assert.Equal(4, vm.Marks.Count);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
+ [AvaloniaFact]
+ public void A_mark_being_drawn_shows_no_outline_or_handles_and_a_picked_one_does()
+ {
+ var (window, vm, _) = Open();
+ try
+ {
+ foreach (var mode in new[] { AnnotateMode.Pen, AnnotateMode.Arrow, AnnotateMode.Box })
+ {
+ vm.Mode = mode;
+ window.MouseDown(On(window, 0.2, 0.3), MouseButton.Left);
+ window.MouseMove(On(window, 0.4, 0.5));
+ window.MouseMove(On(window, 0.6, 0.6));
+ Dispatcher.UIThread.RunJobs();
+ Assert.True(vm.Marks.Selected >= 0, $"{mode}: the drag extends the mark it drew");
+ Assert.False(window.AnnotationCanvas.ShowsSelection, $"{mode} while drawing");
+ window.MouseUp(On(window, 0.6, 0.6), MouseButton.Left);
+ Dispatcher.UIThread.RunJobs();
+ }
+
+ Key(window, Avalonia.Input.Key.V);
+ Click(window, 0.2, 0.45);
+ Assert.True(window.AnnotationCanvas.ShowsSelection);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
+ [AvaloniaFact]
+ public void Numbers_count_up_and_select_moves_resizes_restyles_and_deletes()
+ {
+ var (window, vm, _) = Open();
+ try
+ {
+ vm.Mode = AnnotateMode.Number;
+ Click(window, 0.9, 0.2);
+ Click(window, 0.9, 0.4);
+ Assert.Equal([1, 2], vm.Marks.Marks.Select(m => m.Number));
+
+ vm.Mode = AnnotateMode.Box;
+ Drag(window, (0.2, 0.3), (0.5, 0.6));
+ Key(window, Avalonia.Input.Key.V);
+ Assert.Equal(AnnotateMode.Select, vm.Mode);
+
+ // Click the box's edge, drag its body by its edge: it moves.
+ Click(window, 0.2, 0.45);
+ Assert.Equal(2, vm.Marks.Selected);
+ Drag(window, (0.2, 0.45), (0.3, 0.45));
+ Assert.Equal(0.3, vm.Marks.Marks[2].Points[0].X, 2);
+ Assert.Equal(0.6, vm.Marks.Marks[2].Points[1].X, 2);
+ // Its bottom right corner handle resizes it.
+ Drag(window, (0.6, 0.6), (0.7, 0.8));
+ Assert.Equal((0.7, 0.8), (Math.Round(vm.Marks.Marks[2].Points[1].X, 2), Math.Round(vm.Marks.Marks[2].Points[1].Y, 2)));
+ Assert.Equal(0.3, vm.Marks.Marks[2].Points[0].X, 2);
+
+ // Colour and width go to the selected mark.
+ vm.Swatches[4].ChooseCommand.Execute(null);
+ vm.SetWidthCommand.Execute(AnnotationWidth.Thick);
+ Assert.Equal((AnnotateTool.Colors[4], AnnotationWidth.Thick), (vm.Marks.Marks[2].Color, vm.Marks.Marks[2].Width));
+ Assert.Equal(AnnotateTool.Colors[0], vm.Marks.Marks[0].Color);
+
+ Key(window, Avalonia.Input.Key.Delete);
+ Assert.Equal(2, vm.Marks.Count);
+ Key(window, Avalonia.Input.Key.Z, RawInputModifiers.Control);
+ Assert.Equal(3, vm.Marks.Count);
+ }
+ finally
+ {
+ window.Close();
+ }
+ }
+
+ [AvaloniaFact]
+ public void The_arrows_leave_the_picked_mark_alone_while_a_button_in_the_bar_has_the_focus()
+ {
+ var (window, vm, _) = Open();
+ try
+ {
+ vm.Mode = AnnotateMode.Box;
+ Drag(window, (0.2, 0.2), (0.4, 0.4));
+ Key(window, Avalonia.Input.Key.Tab); // picks the box
+ var at = vm.Marks.Marks[0].Points[0].X;
+ var undo = window.GetVisualDescendants().OfType