diff --git a/src/Filee.App/Assets/WhatsNew/2.3.0/annotate.webp b/src/Filee.App/Assets/WhatsNew/2.3.0/annotate.webp
new file mode 100644
index 000000000..467033f9c
Binary files /dev/null and b/src/Filee.App/Assets/WhatsNew/2.3.0/annotate.webp differ
diff --git a/src/Filee.App/Assets/WhatsNew/2.3.0/crop_video.webp b/src/Filee.App/Assets/WhatsNew/2.3.0/crop_video.webp
new file mode 100644
index 000000000..9ab05fd9e
Binary files /dev/null and b/src/Filee.App/Assets/WhatsNew/2.3.0/crop_video.webp differ
diff --git a/src/Filee.App/Assets/WhatsNew/2.3.0/fields.webp b/src/Filee.App/Assets/WhatsNew/2.3.0/fields.webp
new file mode 100644
index 000000000..5dcd6c7c8
Binary files /dev/null and b/src/Filee.App/Assets/WhatsNew/2.3.0/fields.webp differ
diff --git a/src/Filee.App/Assets/WhatsNew/2.3.0/more.webp b/src/Filee.App/Assets/WhatsNew/2.3.0/more.webp
new file mode 100644
index 000000000..2baf529d8
Binary files /dev/null and b/src/Filee.App/Assets/WhatsNew/2.3.0/more.webp differ
diff --git a/src/Filee.App/Assets/WhatsNew/2.3.0/sheets.webp b/src/Filee.App/Assets/WhatsNew/2.3.0/sheets.webp
new file mode 100644
index 000000000..54edbb6b8
Binary files /dev/null and b/src/Filee.App/Assets/WhatsNew/2.3.0/sheets.webp differ
diff --git a/src/Filee.App/Assets/WhatsNew/2.3.0/slides.json b/src/Filee.App/Assets/WhatsNew/2.3.0/slides.json
new file mode 100644
index 000000000..7b0aeabd8
--- /dev/null
+++ b/src/Filee.App/Assets/WhatsNew/2.3.0/slides.json
@@ -0,0 +1,45 @@
+{
+ "version": "2.3.0",
+ "slides": [
+ {
+ "id": "annotate",
+ "media": "annotate.webp",
+ "poster": 155,
+ "title": "whats_new.2_3_0.annotate.title",
+ "text": "whats_new.2_3_0.annotate.text"
+ },
+ {
+ "id": "crop_video",
+ "media": "crop_video.webp",
+ "poster": 44,
+ "title": "whats_new.2_3_0.crop_video.title",
+ "text": "whats_new.2_3_0.crop_video.text"
+ },
+ {
+ "id": "sheets",
+ "media": "sheets.webp",
+ "poster": 88,
+ "title": "whats_new.2_3_0.sheets.title",
+ "text": "whats_new.2_3_0.sheets.text"
+ },
+ {
+ "id": "fields",
+ "media": "fields.webp",
+ "poster": 45,
+ "title": "whats_new.2_3_0.fields.title",
+ "text": "whats_new.2_3_0.fields.text"
+ },
+ {
+ "id": "translate",
+ "media": "translate.webp",
+ "title": "whats_new.2_3_0.translate.title",
+ "text": "whats_new.2_3_0.translate.text"
+ },
+ {
+ "id": "more",
+ "media": "more.webp",
+ "title": "whats_new.2_3_0.more.title",
+ "text": "whats_new.2_3_0.more.text"
+ }
+ ]
+}
diff --git a/src/Filee.App/Assets/WhatsNew/2.3.0/translate.webp b/src/Filee.App/Assets/WhatsNew/2.3.0/translate.webp
new file mode 100644
index 000000000..c148bce38
Binary files /dev/null and b/src/Filee.App/Assets/WhatsNew/2.3.0/translate.webp differ
diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json
index afcc5f611..78427faea 100644
--- a/src/Filee.App/Assets/i18n/ar.json
+++ b/src/Filee.App/Assets/i18n/ar.json
@@ -1376,6 +1376,18 @@
"whats_new.2_0_0.languages.text": "تتوفر الآن الإسبانية والهندية واليابانية والروسية والعربية والبرتغالية البرازيلية، إلى جانب الإنجليزية والكورية والصينية والبولندية.",
"whats_new.2_0_0.more.title": "والمزيد من التحسينات",
"whats_new.2_0_0.more.text": "يمكنك إعادة قائمة دائرية واحدة إلى إعداداتها الافتراضية، والبحث في سجل التحويلات بالكامل، وتحويل المستندات الغوجاراتية والهندية والعربية باستخدام LibreOffice.",
+ "whats_new.2_3_0.annotate.title": "تعليقات على الصور",
+ "whats_new.2_3_0.annotate.text": "ارسم الأسهم والإطارات والتظليل والنصوص والأرقام مباشرة على الصورة. أضف «تعليقات» إلى قائمة دائرية من الإعدادات ← شريط الأدوات الدائري.",
+ "whats_new.2_3_0.crop_video.title": "اقتصاص إطار الفيديو",
+ "whats_new.2_3_0.crop_video.text": "أزل الأشرطة السوداء التي تُكتشف تلقائيًا، أو اقتصص إلى 16:9 أو 9:16 أو 4:3 أو 1:1 أو 21:9. يبقى فيديو HDR بتقنية HDR.",
+ "whats_new.2_3_0.sheets.title": "صفحات لكل ورقة",
+ "whats_new.2_3_0.sheets.text": "ضع 2 أو 4 أو 6 أو 8 أو 9 صفحات PDF على كل ورقة. تبقى الصفحات متجهية فيبقى النص واضحًا.",
+ "whats_new.2_3_0.fields.title": "الصيغ التي تستخدمها فقط",
+ "whats_new.2_3_0.fields.text": "أوقف المجالات التي لا تعمل فيها، مثل CAD أو النماذج الثلاثية الأبعاد أو الخطوط، من الإعدادات ← المجالات. تختفي صيغها من القائمة الدائرية والقوائم.",
+ "whats_new.2_3_0.translate.title": "تسميات توضيحية وترجمة على حاسوبك",
+ "whats_new.2_3_0.translate.text": "أنشئ تسميات توضيحية SRT من الفيديو، وترجم المستندات والكتب الإلكترونية والترجمات بذكاء اصطناعي محلي. لا يغادر أي شيء حاسوبك.",
+ "whats_new.2_3_0.more.title": "والمزيد من التحسينات",
+ "whats_new.2_3_0.more.text": "النسخ أو النقل إلى مجلد، والاقتصاص بالمنظور، وملفات CMYK للطباعة، وملفات Krita وMinecraft، إضافة إلى الإيطالية والإندونيسية والفيتنامية.",
"preset.extract_motion.donut": "استخراج فيديو Live Photo",
"presets.copy_to_folder": "النسخ إلى مجلد",
"presets.copy_hint": "نسخ الملفات والمجلدات دون تحويل. تبقى الأصول في مكانها؛ وتُضاف أرقام إلى الأسماء الموجودة.",
diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json
index 8ae828ea7..14d4070e0 100644
--- a/src/Filee.App/Assets/i18n/de.json
+++ b/src/Filee.App/Assets/i18n/de.json
@@ -1375,6 +1375,18 @@
"whats_new.2_0_0.languages.text": "Spanisch, Hindi, Japanisch, Russisch, Arabisch und brasilianisches Portugiesisch ergänzen Englisch, Koreanisch, Chinesisch und Polnisch.",
"whats_new.2_0_0.more.title": "Und noch mehr",
"whats_new.2_0_0.more.text": "Setze einzelne Ringmenüs zurück, durchsuche den gesamten Verlauf und konvertiere Dokumente auf Gujarati, Hindi oder Arabisch mit LibreOffice.",
+ "whats_new.2_3_0.annotate.title": "Bilder beschriften",
+ "whats_new.2_3_0.annotate.text": "Zeichne Pfeile, Rahmen, Markierungen, Text und Nummern direkt aufs Bild. Leg Beschriften unter Einstellungen → Ringmenü auf ein Ringmenü.",
+ "whats_new.2_3_0.crop_video.title": "Videos zuschneiden",
+ "whats_new.2_3_0.crop_video.text": "Entferne automatisch erkannte schwarze Balken oder schneide auf 16:9, 9:16, 4:3, 1:1 oder 21:9 zu. HDR-Videos bleiben HDR.",
+ "whats_new.2_3_0.sheets.title": "Seiten pro Blatt",
+ "whats_new.2_3_0.sheets.text": "Bring 2, 4, 6, 8 oder 9 PDF-Seiten auf jedes Blatt. Die Seiten bleiben Vektoren, Text bleibt scharf.",
+ "whats_new.2_3_0.fields.title": "Nur die Formate, die du brauchst",
+ "whats_new.2_3_0.fields.text": "Schalte unter Einstellungen → Bereiche aus, womit du nicht arbeitest, etwa CAD, 3D oder Schriftarten. Ihre Formate verschwinden aus dem Ringmenü und den Listen.",
+ "whats_new.2_3_0.translate.title": "Untertitel und Übersetzungen auf deinem PC",
+ "whats_new.2_3_0.translate.text": "Erstelle SRT-Untertitel aus Videos und übersetze Dokumente, E-Books und Untertitel mit einer lokalen KI. Nichts verlässt deinen PC.",
+ "whats_new.2_3_0.more.title": "Und noch mehr",
+ "whats_new.2_3_0.more.text": "In Ordner kopieren oder verschieben, perspektivisch zuschneiden, CMYK-Druckdateien, Krita- und Minecraft-Dateien sowie Italienisch, Indonesisch und Vietnamesisch.",
"preset.extract_motion.donut": "Video extrahieren",
"presets.copy_to_folder": "In Ordner kopieren",
"presets.copy_hint": "Dateien und Ordner ohne Konvertierung kopieren. Die Originale bleiben, wo sie sind; vorhandene Namen bekommen eine Nummer.",
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index 5a05a178b..3d742ecd7 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -1397,6 +1397,18 @@
"whats_new.2_0_0.languages.text": "Spanish, Hindi, Japanese, Russian, Arabic and Brazilian Portuguese join English, Korean, Chinese and Polish.",
"whats_new.2_0_0.more.title": "And a little more",
"whats_new.2_0_0.more.text": "Reset a single donut to its defaults, search your whole history, and convert Gujarati, Hindi or Arabic documents through LibreOffice.",
+ "whats_new.2_3_0.annotate.title": "Annotate pictures",
+ "whats_new.2_3_0.annotate.text": "Draw arrows, boxes, highlights, text and numbers right on a picture. Add Annotate to a donut in Settings → Donut toolbar.",
+ "whats_new.2_3_0.crop_video.title": "Crop videos",
+ "whats_new.2_3_0.crop_video.text": "Remove black bars found on their own, or crop to 16:9, 9:16, 4:3, 1:1 or 21:9. HDR video stays HDR.",
+ "whats_new.2_3_0.sheets.title": "Pages per sheet",
+ "whats_new.2_3_0.sheets.text": "Put 2, 4, 6, 8 or 9 PDF pages on each sheet. The pages stay vector, so text stays sharp.",
+ "whats_new.2_3_0.fields.title": "Only the formats you use",
+ "whats_new.2_3_0.fields.text": "Turn off fields you don't work in, like CAD, 3D or fonts, in Settings → Fields. Their formats leave the donut and the lists.",
+ "whats_new.2_3_0.translate.title": "Captions and translations on your PC",
+ "whats_new.2_3_0.translate.text": "Make SRT captions from a video, and translate documents, e-books and subtitles with a local AI. Nothing leaves your PC.",
+ "whats_new.2_3_0.more.title": "And a little more",
+ "whats_new.2_3_0.more.text": "Copy or move to a folder, perspective crop, CMYK print files, Krita and Minecraft files, and Italian, Indonesian and Vietnamese.",
"preset.extract_motion.donut": "Extract video",
"presets.copy_to_folder": "Copy to folder",
"presets.copy_hint": "Copy files and folders without conversion. Originals stay where they are; existing names get a number.",
diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json
index 245323230..f133bd4ad 100644
--- a/src/Filee.App/Assets/i18n/es.json
+++ b/src/Filee.App/Assets/i18n/es.json
@@ -1377,6 +1377,18 @@
"whats_new.2_0_0.languages.text": "El español, el hindi, el japonés, el ruso, el árabe y el portugués de Brasil se suman al inglés, el coreano, el chino y el polaco.",
"whats_new.2_0_0.more.title": "Y un poco más",
"whats_new.2_0_0.more.text": "Restablece un solo menú circular, busca en todo tu historial y convierte documentos en guyaratí, hindi o árabe con LibreOffice.",
+ "whats_new.2_3_0.annotate.title": "Anota tus imágenes",
+ "whats_new.2_3_0.annotate.text": "Dibuja flechas, recuadros, resaltados, texto y números directamente sobre la imagen. Añade Anotar a un menú en Ajustes → Menú circular.",
+ "whats_new.2_3_0.crop_video.title": "Recorta vídeos",
+ "whats_new.2_3_0.crop_video.text": "Quita las bandas negras detectadas automáticamente o recorta a 16:9, 9:16, 4:3, 1:1 o 21:9. El vídeo HDR sigue en HDR.",
+ "whats_new.2_3_0.sheets.title": "Páginas por hoja",
+ "whats_new.2_3_0.sheets.text": "Pon 2, 4, 6, 8 o 9 páginas de un PDF en cada hoja. Las páginas siguen siendo vectoriales y el texto se ve nítido.",
+ "whats_new.2_3_0.fields.title": "Solo los formatos que usas",
+ "whats_new.2_3_0.fields.text": "En Ajustes → Áreas, desactiva las que no usas, como CAD, 3D o fuentes. Sus formatos desaparecen del menú circular y de las listas.",
+ "whats_new.2_3_0.translate.title": "Subtítulos y traducciones en tu equipo",
+ "whats_new.2_3_0.translate.text": "Crea subtítulos SRT a partir de un vídeo y traduce documentos, libros electrónicos y subtítulos con una IA local. Nada sale de tu equipo.",
+ "whats_new.2_3_0.more.title": "Y un poco más",
+ "whats_new.2_3_0.more.text": "Copiar o mover a una carpeta, recorte con perspectiva, archivos CMYK para imprenta, archivos de Krita y Minecraft, e italiano, indonesio y vietnamita.",
"preset.extract_motion.donut": "Extraer vídeo de Live Photo",
"presets.copy_to_folder": "Copiar a carpeta",
"presets.copy_hint": "Copia archivos y carpetas sin convertirlos. Los originales se quedan donde están; los nombres que ya existen reciben un número.",
diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json
index 93316bbbc..7e6b2bfb1 100644
--- a/src/Filee.App/Assets/i18n/fr.json
+++ b/src/Filee.App/Assets/i18n/fr.json
@@ -1397,6 +1397,18 @@
"whats_new.2_0_0.languages.text": "L'espagnol, l'hindi, le japonais, le russe, l'arabe et le portugais du Brésil rejoignent l'anglais, le coréen, le chinois et le polonais.",
"whats_new.2_0_0.more.title": "Et encore un peu plus",
"whats_new.2_0_0.more.text": "Réinitialisez un seul menu circulaire, recherchez dans tout votre historique, et convertissez des documents en gujarati, hindi ou arabe via LibreOffice.",
+ "whats_new.2_3_0.annotate.title": "Annoter les images",
+ "whats_new.2_3_0.annotate.text": "Dessinez flèches, cadres, surlignages, texte et numéros directement sur l'image. Ajoutez Annoter à un menu dans Paramètres → Menu circulaire.",
+ "whats_new.2_3_0.crop_video.title": "Recadrer les vidéos",
+ "whats_new.2_3_0.crop_video.text": "Supprimez les bandes noires détectées automatiquement ou recadrez en 16:9, 9:16, 4:3, 1:1 ou 21:9. Une vidéo HDR reste en HDR.",
+ "whats_new.2_3_0.sheets.title": "Pages par feuille",
+ "whats_new.2_3_0.sheets.text": "Placez 2, 4, 6, 8 ou 9 pages PDF sur chaque feuille. Les pages restent vectorielles et le texte reste net.",
+ "whats_new.2_3_0.fields.title": "Seulement les formats qui vous servent",
+ "whats_new.2_3_0.fields.text": "Dans Paramètres → Domaines, désactivez ceux où vous ne travaillez pas, comme la CAO, la 3D ou les polices. Leurs formats quittent le menu circulaire et les listes.",
+ "whats_new.2_3_0.translate.title": "Sous-titres et traductions sur votre PC",
+ "whats_new.2_3_0.translate.text": "Créez des sous-titres SRT à partir d'une vidéo et traduisez documents, livres numériques et sous-titres avec une IA locale. Rien ne quitte votre PC.",
+ "whats_new.2_3_0.more.title": "Et encore un peu plus",
+ "whats_new.2_3_0.more.text": "Copier ou déplacer dans un dossier, recadrage en perspective, fichiers CMJN pour l'impression, fichiers Krita et Minecraft, ainsi que l'italien, l'indonésien et le vietnamien.",
"preset.extract_motion.donut": "Vidéo Live",
"presets.copy_to_folder": "Copier dans un dossier",
"presets.copy_hint": "Copie les fichiers et dossiers sans conversion. Les originaux restent où ils sont ; les noms déjà pris reçoivent un numéro.",
diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json
index ec966f1fc..3f38eeb4c 100644
--- a/src/Filee.App/Assets/i18n/hi.json
+++ b/src/Filee.App/Assets/i18n/hi.json
@@ -1377,6 +1377,18 @@
"whats_new.2_0_0.languages.text": "अंग्रेज़ी, कोरियाई, चीनी और पोलिश के साथ अब स्पैनिश, हिन्दी, जापानी, रूसी, अरबी और ब्राज़ीलियाई पुर्तगाली भी।",
"whats_new.2_0_0.more.title": "और भी थोड़ा",
"whats_new.2_0_0.more.text": "एक ही डोनट को डिफ़ॉल्ट पर रीसेट करें, पूरा इतिहास खोजें, और गुजराती, हिन्दी या अरबी दस्तावेज़ LibreOffice से बदलें।",
+ "whats_new.2_3_0.annotate.title": "तस्वीरों पर टिप्पणी",
+ "whats_new.2_3_0.annotate.text": "तस्वीर पर सीधे तीर, फ़्रेम, हाइलाइट, टेक्स्ट और नंबर बनाएँ। सेटिंग्स → डोनट टूलबार में टिप्पणी को किसी डोनट पर जोड़ें।",
+ "whats_new.2_3_0.crop_video.title": "वीडियो क्रॉप करें",
+ "whats_new.2_3_0.crop_video.text": "अपने आप मिली काली पट्टियाँ हटाएँ, या 16:9, 9:16, 4:3, 1:1 या 21:9 में क्रॉप करें। HDR वीडियो HDR ही रहता है।",
+ "whats_new.2_3_0.sheets.title": "प्रति शीट पेज",
+ "whats_new.2_3_0.sheets.text": "हर शीट पर PDF के 2, 4, 6, 8 या 9 पेज रखें। पेज वेक्टर ही रहते हैं, इसलिए टेक्स्ट साफ़ दिखता है।",
+ "whats_new.2_3_0.fields.title": "सिर्फ़ आपके काम के फ़ॉर्मैट",
+ "whats_new.2_3_0.fields.text": "सेटिंग्स → क्षेत्र में वे क्षेत्र बंद करें जिनमें आप काम नहीं करते, जैसे CAD, 3D या फ़ॉन्ट। उनके फ़ॉर्मैट डोनट और सूचियों से हट जाते हैं।",
+ "whats_new.2_3_0.translate.title": "आपके PC पर उपशीर्षक और अनुवाद",
+ "whats_new.2_3_0.translate.text": "वीडियो से SRT उपशीर्षक बनाएँ, और दस्तावेज़, ई-बुक और उपशीर्षक लोकल AI से अनुवाद करें। कुछ भी आपके PC से बाहर नहीं जाता।",
+ "whats_new.2_3_0.more.title": "और भी थोड़ा",
+ "whats_new.2_3_0.more.text": "फ़ोल्डर में कॉपी करें या ले जाएँ, परिप्रेक्ष्य क्रॉप, प्रिंट के लिए CMYK फ़ाइलें, Krita और Minecraft फ़ाइलें, और इतालवी, इंडोनेशियाई व वियतनामी भाषा।",
"preset.extract_motion.donut": "लाइव फ़ोटो वीडियो निकालें",
"presets.copy_to_folder": "फ़ोल्डर में कॉपी करें",
"presets.copy_hint": "फ़ाइलें और फ़ोल्डर बिना रूपांतरण के कॉपी करें। मूल फ़ाइलें अपनी जगह रहती हैं; पहले से मौजूद नामों में एक संख्या जुड़ जाती है।",
diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json
index 8e9f47d42..c265c4ba5 100644
--- a/src/Filee.App/Assets/i18n/id.json
+++ b/src/Filee.App/Assets/i18n/id.json
@@ -1375,6 +1375,18 @@
"whats_new.2_0_0.languages.text": "Bahasa Spanyol, Hindi, Jepang, Rusia, Arab, dan Portugis Brasil bergabung dengan bahasa Inggris, Korea, Mandarin, dan Polandia.",
"whats_new.2_0_0.more.title": "Dan masih banyak lagi",
"whats_new.2_0_0.more.text": "Atur ulang satu menu donut ke bawaan, cari seluruh riwayat Anda, dan konversi dokumen Gujarati, Hindi, atau Arab melalui LibreOffice.",
+ "whats_new.2_3_0.annotate.title": "Anotasi gambar",
+ "whats_new.2_3_0.annotate.text": "Gambar panah, bingkai, sorotan, teks, dan nomor langsung di atas gambar. Tambahkan Anotasi ke donut di Pengaturan → Toolbar donut.",
+ "whats_new.2_3_0.crop_video.title": "Pangkas video",
+ "whats_new.2_3_0.crop_video.text": "Hapus bilah hitam yang ditemukan otomatis, atau pangkas ke 16:9, 9:16, 4:3, 1:1, atau 21:9. Video HDR tetap HDR.",
+ "whats_new.2_3_0.sheets.title": "Halaman per lembar",
+ "whats_new.2_3_0.sheets.text": "Taruh 2, 4, 6, 8, atau 9 halaman PDF di setiap lembar. Halaman tetap vektor, jadi teks tetap tajam.",
+ "whats_new.2_3_0.fields.title": "Hanya format yang Anda pakai",
+ "whats_new.2_3_0.fields.text": "Matikan bidang yang tidak Anda kerjakan, seperti CAD, 3D, atau font, di Pengaturan → Bidang. Formatnya hilang dari donut dan daftar.",
+ "whats_new.2_3_0.translate.title": "Subtitle dan terjemahan di PC Anda",
+ "whats_new.2_3_0.translate.text": "Buat subtitle SRT dari video, dan terjemahkan dokumen, e-book, serta subtitle dengan AI lokal. Tidak ada yang keluar dari PC Anda.",
+ "whats_new.2_3_0.more.title": "Dan masih banyak lagi",
+ "whats_new.2_3_0.more.text": "Salin atau pindahkan ke folder, pangkas perspektif, file CMYK untuk cetak, file Krita dan Minecraft, serta bahasa Italia, Indonesia, dan Vietnam.",
"preset.extract_motion.donut": "Ekstrak video",
"presets.copy_to_folder": "Salin ke folder",
"presets.copy_hint": "Salin file dan folder tanpa konversi. File asli tetap di tempatnya; nama yang sudah ada diberi nomor.",
diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json
index f19eda072..7f3147ee7 100644
--- a/src/Filee.App/Assets/i18n/it.json
+++ b/src/Filee.App/Assets/i18n/it.json
@@ -1374,6 +1374,18 @@
"whats_new.2_0_0.languages.text": "Spagnolo, hindi, giapponese, russo, arabo e portoghese brasiliano si uniscono a inglese, coreano, cinese e polacco.",
"whats_new.2_0_0.more.title": "E qualcosa in più",
"whats_new.2_0_0.more.text": "Ripristina un singolo donut ai valori predefiniti, cerca in tutta la cronologia e converti documenti in gujarati, hindi o arabo tramite LibreOffice.",
+"whats_new.2_3_0.annotate.title": "Annota le immagini",
+"whats_new.2_3_0.annotate.text": "Disegna frecce, cornici, evidenziazioni, testo e numeri direttamente sull'immagine. Aggiungi Annota a un donut in Impostazioni → Barra donut.",
+"whats_new.2_3_0.crop_video.title": "Ritaglia i video",
+"whats_new.2_3_0.crop_video.text": "Rimuovi le bande nere trovate in automatico o ritaglia in 16:9, 9:16, 4:3, 1:1 o 21:9. I video HDR restano HDR.",
+"whats_new.2_3_0.sheets.title": "Pagine per foglio",
+"whats_new.2_3_0.sheets.text": "Metti 2, 4, 6, 8 o 9 pagine PDF su ogni foglio. Le pagine restano vettoriali e il testo resta nitido.",
+"whats_new.2_3_0.fields.title": "Solo i formati che usi",
+"whats_new.2_3_0.fields.text": "In Impostazioni → Settori disattiva quelli in cui non lavori, come CAD, 3D o caratteri. I loro formati spariscono dal donut e dagli elenchi.",
+"whats_new.2_3_0.translate.title": "Sottotitoli e traduzioni sul tuo PC",
+"whats_new.2_3_0.translate.text": "Crea sottotitoli SRT da un video e traduci documenti, ebook e sottotitoli con un'IA locale. Niente lascia il tuo PC.",
+"whats_new.2_3_0.more.title": "E qualcosa in più",
+"whats_new.2_3_0.more.text": "Copia o sposta in una cartella, ritaglio prospettico, file CMYK per la stampa, file di Krita e Minecraft, e in più italiano, indonesiano e vietnamita.",
"preset.extract_motion.donut": "Estrai video",
"presets.copy_to_folder": "Copia nella cartella",
"presets.copy_hint": "Copia file e cartelle senza conversione. Gli originali restano dove sono; ai nomi già esistenti si aggiunge un numero.",
diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json
index d56112db9..6bd798b3e 100644
--- a/src/Filee.App/Assets/i18n/ja.json
+++ b/src/Filee.App/Assets/i18n/ja.json
@@ -1377,6 +1377,18 @@
"whats_new.2_0_0.languages.text": "英語、韓国語、中国語、ポーランド語に、スペイン語、ヒンディー語、日本語、ロシア語、アラビア語、ブラジルポルトガル語が加わりました。",
"whats_new.2_0_0.more.title": "ほかにも",
"whats_new.2_0_0.more.text": "ドーナツを 1 つだけ既定に戻す、履歴全体を検索する、グジャラート語・ヒンディー語・アラビア語の文書を LibreOffice で変換する、など。",
+ "whats_new.2_3_0.annotate.title": "画像に注釈",
+ "whats_new.2_3_0.annotate.text": "矢印、枠、ハイライト、文字、番号を画像に直接描けます。「設定」→「ドーナツメニュー」で注釈をドーナツメニューに追加してください。",
+ "whats_new.2_3_0.crop_video.title": "動画のトリミング",
+ "whats_new.2_3_0.crop_video.text": "自動で見つけた黒帯を削除するか、16:9、9:16、4:3、1:1、21:9 にトリミングします。HDR 動画は HDR のままです。",
+ "whats_new.2_3_0.sheets.title": "ページ割り付け",
+ "whats_new.2_3_0.sheets.text": "PDF の 2、4、6、8、9 ページを 1 枚に割り付けます。ページはベクターのままなので、文字もくっきりです。",
+ "whats_new.2_3_0.fields.title": "使う形式だけを表示",
+ "whats_new.2_3_0.fields.text": "「設定」→「分野」で CAD、3D、フォントなど使わない分野をオフにすると、その形式がドーナツメニューと一覧から消えます。",
+ "whats_new.2_3_0.translate.title": "字幕と翻訳を PC の中で",
+ "whats_new.2_3_0.translate.text": "動画から SRT 字幕を作成し、文書、電子書籍、字幕をローカル AI で翻訳します。ファイルが PC の外に出ることはありません。",
+ "whats_new.2_3_0.more.title": "ほかにも",
+ "whats_new.2_3_0.more.text": "フォルダーへのコピー・移動、遠近補正のトリミング、印刷用 CMYK ファイル、Krita と Minecraft のファイル、イタリア語、インドネシア語、ベトナム語に対応しました。",
"preset.extract_motion.donut": "Live Photoの動画を抽出",
"presets.copy_to_folder": "フォルダーにコピー",
"presets.copy_hint": "ファイルとフォルダーを変換せずにコピーします。元のファイルはそのまま残り、同じ名前があれば番号が付きます。",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index 6616a4744..377fda003 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -1397,6 +1397,18 @@
"whats_new.2_0_0.languages.text": "영어, 한국어, 중국어, 폴란드어에 스페인어, 힌디어, 일본어, 러시아어, 아랍어, 브라질 포르투갈어가 더해졌어요.",
"whats_new.2_0_0.more.title": "그리고 조금 더",
"whats_new.2_0_0.more.text": "원형 툴바 하나만 기본값으로 초기화하고, 기록 전체를 검색하고, 구자라트어·힌디어·아랍어 문서를 LibreOffice로 변환할 수 있어요.",
+ "whats_new.2_3_0.annotate.title": "사진에 주석 달기",
+ "whats_new.2_3_0.annotate.text": "사진 위에 화살표, 상자, 강조, 글자, 번호를 바로 그려요. 설정 → 원형 툴바에서 주석을 툴바에 넣어 보세요.",
+ "whats_new.2_3_0.crop_video.title": "동영상 화면 자르기",
+ "whats_new.2_3_0.crop_video.text": "검은 테두리를 알아서 찾아 없애거나 16:9, 9:16, 4:3, 1:1, 21:9로 잘라요. HDR 동영상은 HDR 그대로 남아요.",
+ "whats_new.2_3_0.sheets.title": "모아찍기",
+ "whats_new.2_3_0.sheets.text": "PDF 2·4·6·8·9쪽을 한 장에 모아요. 페이지가 벡터 그대로라 글자가 선명해요.",
+ "whats_new.2_3_0.fields.title": "쓰는 형식만 보기",
+ "whats_new.2_3_0.fields.text": "설정 → 분야에서 CAD, 3D, 글꼴처럼 쓰지 않는 분야를 끄면 그 형식이 원형 툴바와 목록에서 빠져요.",
+ "whats_new.2_3_0.translate.title": "내 PC에서 자막과 번역",
+ "whats_new.2_3_0.translate.text": "동영상에서 SRT 자막을 만들고, 문서·전자책·자막을 내 PC의 AI로 번역해요. 파일은 PC 밖으로 나가지 않아요.",
+ "whats_new.2_3_0.more.title": "그리고 조금 더",
+ "whats_new.2_3_0.more.text": "폴더로 복사·이동, 원근 보정 자르기, 인쇄용 CMYK 파일, Krita·마인크래프트 파일, 이탈리아어·인도네시아어·베트남어가 더해졌어요.",
"preset.extract_motion.donut": "라이브 포토 동영상 추출",
"presets.copy_to_folder": "폴더로 복사",
"presets.copy_hint": "파일과 폴더를 변환하지 않고 복사해요. 원본은 그 자리에 남고, 같은 이름이 있으면 번호가 붙어요.",
diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json
index cf7179243..6f461a67d 100644
--- a/src/Filee.App/Assets/i18n/ne.json
+++ b/src/Filee.App/Assets/i18n/ne.json
@@ -1377,6 +1377,18 @@
"whats_new.2_0_0.languages.text": "अङ्ग्रेजी, कोरियाली, चिनियाँ र पोलिससँगै अब स्पेनिस, हिन्दी, जापानी, रसियन, अरबी र ब्राजिलियन पोर्चुगिज पनि।",
"whats_new.2_0_0.more.title": "र थोरै अरू पनि",
"whats_new.2_0_0.more.text": "एउटै डोनटलाई पूर्वनिर्धारितमा रिसेट गर्नुहोस्, सम्पूर्ण इतिहास खोज्नुहोस्, र LibreOffice मार्फत गुजराती, हिन्दी वा अरबी कागजातहरू रूपान्तरण गर्नुहोस्।",
+ "whats_new.2_3_0.annotate.title": "तस्बिरमा टिप्पणी",
+ "whats_new.2_3_0.annotate.text": "तस्बिरमै तीर, फ्रेम, हाइलाइट, पाठ र नम्बर कोर्नुहोस्। सेटिङहरू → डोनट टुलबारमा टिप्पणीलाई कुनै डोनटमा थप्नुहोस्।",
+ "whats_new.2_3_0.crop_video.title": "भिडियो क्रप गर्नुहोस्",
+ "whats_new.2_3_0.crop_video.text": "आफैँ भेटिएका कालो पट्टी हटाउनुहोस्, वा 16:9, 9:16, 4:3, 1:1 वा 21:9 मा क्रप गर्नुहोस्। HDR भिडियो HDR नै रहन्छ।",
+ "whats_new.2_3_0.sheets.title": "प्रति पाना पेज",
+ "whats_new.2_3_0.sheets.text": "हरेक पानामा PDF का 2, 4, 6, 8 वा 9 पेज राख्नुहोस्। पेजहरू भेक्टर नै रहन्छन्, त्यसैले पाठ स्पष्ट देखिन्छ।",
+ "whats_new.2_3_0.fields.title": "तपाईंले चलाउने ढाँचा मात्र",
+ "whats_new.2_3_0.fields.text": "सेटिङहरू → क्षेत्रहरूमा तपाईं काम नगर्ने क्षेत्रहरू, जस्तै CAD, 3D वा फन्ट, बन्द गर्नुहोस्। तिनका ढाँचाहरू डोनट र सूचीहरूबाट हट्छन्।",
+ "whats_new.2_3_0.translate.title": "तपाईंको कम्प्युटरमै उपशीर्षक र अनुवाद",
+ "whats_new.2_3_0.translate.text": "भिडियोबाट SRT उपशीर्षक बनाउनुहोस्, र कागजात, ई-पुस्तक र उपशीर्षक स्थानीय AI ले अनुवाद गर्नुहोस्। केही पनि तपाईंको कम्प्युटर बाहिर जाँदैन।",
+ "whats_new.2_3_0.more.title": "र थोरै अरू पनि",
+ "whats_new.2_3_0.more.text": "फोल्डरमा प्रतिलिपि वा सार्ने, परिप्रेक्ष्य क्रप, मुद्रणका लागि CMYK फाइल, Krita र Minecraft फाइल, साथै इटालियन, इन्डोनेसियाली र भियतनामी भाषा।",
"preset.extract_motion.donut": "लाइभ फोटो भिडियो निकाल्नुहोस्",
"presets.copy_to_folder": "फोल्डरमा प्रतिलिपि गर्नुहोस्",
"presets.copy_hint": "फाइल र फोल्डरहरू रूपान्तरण नगरी प्रतिलिपि गर्नुहोस्। मूल फाइलहरू आफ्नै ठाउँमा रहन्छन्; पहिल्यै भएका नाममा एउटा अङ्क थपिन्छ।",
diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json
index 93283f353..51fe0e3bc 100644
--- a/src/Filee.App/Assets/i18n/pl.json
+++ b/src/Filee.App/Assets/i18n/pl.json
@@ -1397,6 +1397,18 @@
"whats_new.2_0_0.languages.text": "Do angielskiego, koreańskiego, chińskiego i polskiego dołączają hiszpański, hindi, japoński, rosyjski, arabski i portugalski brazylijski.",
"whats_new.2_0_0.more.title": "I jeszcze trochę",
"whats_new.2_0_0.more.text": "Przywróć domyślne ustawienia jednego pierścienia, przeszukuj całą historię i konwertuj dokumenty w gudżarati, hindi lub arabskim przez LibreOffice.",
+ "whats_new.2_3_0.annotate.title": "Adnotacje na obrazach",
+ "whats_new.2_3_0.annotate.text": "Rysuj strzałki, ramki, zakreślenia, tekst i numery bezpośrednio na obrazie. Dodaj Adnotacje do pierścienia w Ustawienia → Pasek-pierścień.",
+ "whats_new.2_3_0.crop_video.title": "Kadrowanie wideo",
+ "whats_new.2_3_0.crop_video.text": "Usuń automatycznie wykryte czarne pasy albo wykadruj do 16:9, 9:16, 4:3, 1:1 lub 21:9. Wideo HDR zostaje w HDR.",
+ "whats_new.2_3_0.sheets.title": "Stron na arkusz",
+ "whats_new.2_3_0.sheets.text": "Umieść 2, 4, 6, 8 lub 9 stron PDF na każdym arkuszu. Strony pozostają wektorowe, więc tekst jest ostry.",
+ "whats_new.2_3_0.fields.title": "Tylko formaty, których używasz",
+ "whats_new.2_3_0.fields.text": "Wyłącz w Ustawienia → Dziedziny to, czym się nie zajmujesz, np. CAD, 3D czy czcionki. Ich formaty znikną z pierścienia i z list.",
+ "whats_new.2_3_0.translate.title": "Napisy i tłumaczenia na twoim komputerze",
+ "whats_new.2_3_0.translate.text": "Twórz napisy SRT z wideo i tłumacz dokumenty, e-booki oraz napisy lokalną AI. Nic nie opuszcza twojego komputera.",
+ "whats_new.2_3_0.more.title": "I jeszcze trochę",
+ "whats_new.2_3_0.more.text": "Kopiowanie lub przenoszenie do folderu, kadrowanie z perspektywą, pliki CMYK do druku, pliki Krity i Minecrafta oraz włoski, indonezyjski i wietnamski.",
"preset.extract_motion.donut": "Wyodrębnij wideo z Live Photo",
"presets.copy_to_folder": "Kopiuj do folderu",
"presets.copy_hint": "Kopiuje pliki i foldery bez konwersji. Oryginały zostają na miejscu; istniejące nazwy dostają numer.",
diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json
index 64190478a..d8dd45860 100644
--- a/src/Filee.App/Assets/i18n/pt-BR.json
+++ b/src/Filee.App/Assets/i18n/pt-BR.json
@@ -1397,6 +1397,18 @@
"whats_new.2_0_0.languages.text": "Espanhol, hindi, japonês, russo, árabe e português do Brasil se juntam ao inglês, coreano, chinês e polonês.",
"whats_new.2_0_0.more.title": "E um pouco mais",
"whats_new.2_0_0.more.text": "Restaure um único menu aos padrões, pesquise todo o histórico e converta documentos em guzerate, hindi ou árabe pelo LibreOffice.",
+ "whats_new.2_3_0.annotate.title": "Anote suas imagens",
+ "whats_new.2_3_0.annotate.text": "Desenhe setas, molduras, destaques, texto e números direto na imagem. Adicione Anotar a um menu em Configurações → Menu circular.",
+ "whats_new.2_3_0.crop_video.title": "Recorte vídeos",
+ "whats_new.2_3_0.crop_video.text": "Remova as barras pretas encontradas automaticamente ou recorte em 16:9, 9:16, 4:3, 1:1 ou 21:9. Vídeo HDR continua HDR.",
+ "whats_new.2_3_0.sheets.title": "Páginas por folha",
+ "whats_new.2_3_0.sheets.text": "Coloque 2, 4, 6, 8 ou 9 páginas de PDF em cada folha. As páginas continuam vetoriais e o texto fica nítido.",
+ "whats_new.2_3_0.fields.title": "Só os formatos que você usa",
+ "whats_new.2_3_0.fields.text": "Em Configurações → Áreas, desative as que você não usa, como CAD, 3D ou fontes. Os formatos delas saem do menu circular e das listas.",
+ "whats_new.2_3_0.translate.title": "Legendas e traduções no seu PC",
+ "whats_new.2_3_0.translate.text": "Crie legendas SRT a partir de um vídeo e traduza documentos, e-books e legendas com uma IA local. Nada sai do seu PC.",
+ "whats_new.2_3_0.more.title": "E um pouco mais",
+ "whats_new.2_3_0.more.text": "Copiar ou mover para uma pasta, recorte com perspectiva, arquivos CMYK para impressão, arquivos do Krita e do Minecraft, além de italiano, indonésio e vietnamita.",
"preset.extract_motion.donut": "Extrair vídeo",
"presets.copy_to_folder": "Copiar para pasta",
"presets.copy_hint": "Copia arquivos e pastas sem conversão. Os originais ficam onde estão; nomes que já existem recebem um número.",
diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json
index 92e0a62ed..004be1cc8 100644
--- a/src/Filee.App/Assets/i18n/pt-PT.json
+++ b/src/Filee.App/Assets/i18n/pt-PT.json
@@ -1397,6 +1397,18 @@
"whats_new.2_0_0.languages.text": "O espanhol, o hindi, o japonês, o russo, o árabe e o português do Brasil juntam-se ao inglês, ao coreano, ao chinês e ao polaco.",
"whats_new.2_0_0.more.title": "E mais algumas novidades",
"whats_new.2_0_0.more.text": "Repõe os valores de origem de uma só barra, pesquisa em todo o histórico e converte documentos em guzerate, hindi ou árabe através do LibreOffice.",
+ "whats_new.2_3_0.annotate.title": "Anota as tuas imagens",
+ "whats_new.2_3_0.annotate.text": "Desenha setas, molduras, destaques, texto e números diretamente na imagem. Adiciona Anotar a uma barra em Definições → Barra circular.",
+ "whats_new.2_3_0.crop_video.title": "Recorta vídeos",
+ "whats_new.2_3_0.crop_video.text": "Remove as barras pretas detetadas automaticamente ou recorta em 16:9, 9:16, 4:3, 1:1 ou 21:9. O vídeo HDR continua em HDR.",
+ "whats_new.2_3_0.sheets.title": "Páginas por folha",
+ "whats_new.2_3_0.sheets.text": "Coloca 2, 4, 6, 8 ou 9 páginas de PDF em cada folha. As páginas continuam vetoriais e o texto fica nítido.",
+ "whats_new.2_3_0.fields.title": "Só os formatos que usas",
+ "whats_new.2_3_0.fields.text": "Em Definições → Áreas, desativa as que não usas, como CAD, 3D ou tipos de letra. Os formatos delas saem da barra circular e das listas.",
+ "whats_new.2_3_0.translate.title": "Legendas e traduções no teu computador",
+ "whats_new.2_3_0.translate.text": "Cria legendas SRT a partir de um vídeo e traduz documentos, livros digitais e legendas com uma IA local. Nada sai do teu computador.",
+ "whats_new.2_3_0.more.title": "E mais algumas novidades",
+ "whats_new.2_3_0.more.text": "Copiar ou mover para uma pasta, recorte em perspetiva, ficheiros CMYK para impressão, ficheiros do Krita e do Minecraft, e ainda italiano, indonésio e vietnamita.",
"preset.extract_motion.donut": "Extrair vídeo",
"presets.copy_to_folder": "Copiar para pasta",
"presets.copy_hint": "Copia ficheiros e pastas sem conversão. Os originais ficam onde estão; os nomes que já existem recebem um número.",
diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json
index c6ef7e033..a91f89e6c 100644
--- a/src/Filee.App/Assets/i18n/ru.json
+++ b/src/Filee.App/Assets/i18n/ru.json
@@ -1397,6 +1397,18 @@
"whats_new.2_0_0.languages.text": "К английскому, корейскому, китайскому и польскому добавились испанский, хинди, японский, русский, арабский и бразильский португальский.",
"whats_new.2_0_0.more.title": "И ещё немного",
"whats_new.2_0_0.more.text": "Сбрасывайте одно круговое меню к настройкам по умолчанию, ищите по всей истории и конвертируйте документы на гуджарати, хинди и арабском через LibreOffice.",
+ "whats_new.2_3_0.annotate.title": "Пометки на картинках",
+ "whats_new.2_3_0.annotate.text": "Рисуйте стрелки, рамки, выделение, текст и номера прямо на картинке. Добавьте «Пометки» в меню в «Настройки → Круговое меню».",
+ "whats_new.2_3_0.crop_video.title": "Обрезка кадра видео",
+ "whats_new.2_3_0.crop_video.text": "Уберите чёрные полосы, найденные автоматически, или обрежьте кадр до 16:9, 9:16, 4:3, 1:1 или 21:9. HDR-видео остаётся в HDR.",
+ "whats_new.2_3_0.sheets.title": "Страниц на листе",
+ "whats_new.2_3_0.sheets.text": "Размещайте 2, 4, 6, 8 или 9 страниц PDF на каждом листе. Страницы остаются векторными, и текст остаётся чётким.",
+ "whats_new.2_3_0.fields.title": "Только нужные форматы",
+ "whats_new.2_3_0.fields.text": "Отключите в «Настройки → Области» то, с чем не работаете, например САПР, 3D или шрифты. Их форматы исчезнут из кругового меню и списков.",
+ "whats_new.2_3_0.translate.title": "Субтитры и перевод на вашем компьютере",
+ "whats_new.2_3_0.translate.text": "Создавайте субтитры SRT из видео и переводите документы, электронные книги и субтитры локальным ИИ. Ничего не покидает ваш компьютер.",
+ "whats_new.2_3_0.more.title": "И ещё немного",
+ "whats_new.2_3_0.more.text": "Копирование и перемещение в папку, обрезка с перспективой, файлы CMYK для печати, файлы Krita и Minecraft, а также итальянский, индонезийский и вьетнамский.",
"preset.extract_motion.donut": "Видео из Live Photo",
"presets.copy_to_folder": "Копировать в папку",
"presets.copy_hint": "Копирует файлы и папки без преобразования. Оригиналы остаются на месте; к уже существующим именам добавляется номер.",
diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json
index fd9df7206..814019959 100644
--- a/src/Filee.App/Assets/i18n/th.json
+++ b/src/Filee.App/Assets/i18n/th.json
@@ -1377,6 +1377,18 @@
"whats_new.2_0_0.languages.text": "เพิ่มภาษาสเปน ฮินดี ญี่ปุ่น รัสเซีย อาหรับ และโปรตุเกสบราซิล ร่วมกับอังกฤษ เกาหลี จีน และโปแลนด์",
"whats_new.2_0_0.more.title": "และอีกมากมาย",
"whats_new.2_0_0.more.text": "รีเซ็ตโดนัทแต่ละอันกลับเป็นค่าเริ่มต้น ค้นหาประวัติการแปลงไฟล์ทั้งหมด และแปลงเอกสารภาษาคุชราต ฮินดี หรืออาหรับผ่าน LibreOffice",
+ "whats_new.2_3_0.annotate.title": "ใส่คำอธิบายบนรูปภาพ",
+ "whats_new.2_3_0.annotate.text": "วาดลูกศร กรอบ ไฮไลต์ ข้อความ และหมายเลขลงบนรูปภาพได้โดยตรง เพิ่ม “ใส่คำอธิบาย” ลงในโดนัทได้ที่การตั้งค่า → แถบโดนัท",
+ "whats_new.2_3_0.crop_video.title": "ครอบตัดวิดีโอ",
+ "whats_new.2_3_0.crop_video.text": "ลบแถบดำที่ตรวจพบโดยอัตโนมัติ หรือครอบตัดเป็น 16:9, 9:16, 4:3, 1:1 หรือ 21:9 วิดีโอ HDR ยังคงเป็น HDR",
+ "whats_new.2_3_0.sheets.title": "หลายหน้าต่อแผ่น",
+ "whats_new.2_3_0.sheets.text": "วาง PDF 2, 4, 6, 8 หรือ 9 หน้าลงในแต่ละแผ่น หน้ายังคงเป็นเวกเตอร์ ข้อความจึงคมชัด",
+ "whats_new.2_3_0.fields.title": "เฉพาะรูปแบบที่คุณใช้",
+ "whats_new.2_3_0.fields.text": "ปิดสาขาที่คุณไม่ได้ใช้ เช่น CAD, 3D หรือแบบอักษร ได้ที่การตั้งค่า → สาขา รูปแบบของสาขานั้นจะหายไปจากโดนัทและรายการ",
+ "whats_new.2_3_0.translate.title": "คำบรรยายและการแปลบนคอมพิวเตอร์ของคุณ",
+ "whats_new.2_3_0.translate.text": "สร้างคำบรรยาย SRT จากวิดีโอ และแปลเอกสาร อีบุ๊ก และคำบรรยายด้วย AI ในเครื่อง ไม่มีอะไรออกไปจากคอมพิวเตอร์ของคุณ",
+ "whats_new.2_3_0.more.title": "และอีกมากมาย",
+ "whats_new.2_3_0.more.text": "คัดลอกหรือย้ายไปยังโฟลเดอร์ ครอบตัดแบบเปอร์สเปกทีฟ ไฟล์ CMYK สำหรับงานพิมพ์ ไฟล์ Krita และ Minecraft รวมถึงภาษาอิตาลี อินโดนีเซีย และเวียดนาม",
"preset.extract_motion.donut": "ดึงวิดีโอจาก Live Photo",
"presets.copy_to_folder": "คัดลอกไปยังโฟลเดอร์",
"presets.copy_hint": "คัดลอกไฟล์และโฟลเดอร์โดยไม่แปลง ต้นฉบับยังอยู่ที่เดิม ชื่อที่มีอยู่แล้วจะได้หมายเลขต่อท้าย",
diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json
index 18d1b3bd6..307399a58 100644
--- a/src/Filee.App/Assets/i18n/tr.json
+++ b/src/Filee.App/Assets/i18n/tr.json
@@ -1375,6 +1375,18 @@
"whats_new.2_0_0.languages.text": "İspanyolca, Hintçe, Japonca, Rusça, Arapça ve Brezilya Portekizcesi; İngilizce, Korece, Çince ve Lehçeye katılıyor.",
"whats_new.2_0_0.more.title": "Ve biraz daha fazlası",
"whats_new.2_0_0.more.text": "Tek bir halkayı varsayılanlarına sıfırlayın, tüm geçmişte arama yapın ve Guceratça, Hintçe veya Arapça belgeleri LibreOffice ile dönüştürün.",
+ "whats_new.2_3_0.annotate.title": "Resimlere not ekleyin",
+ "whats_new.2_3_0.annotate.text": "Resmin üzerine doğrudan ok, çerçeve, vurgu, metin ve numara çizin. Not ekle'yi Ayarlar → Halka araç çubuğu bölümünden bir halkaya ekleyin.",
+ "whats_new.2_3_0.crop_video.title": "Videoları kırpın",
+ "whats_new.2_3_0.crop_video.text": "Otomatik bulunan siyah bantları kaldırın ya da 16:9, 9:16, 4:3, 1:1 veya 21:9 oranında kırpın. HDR video HDR olarak kalır.",
+ "whats_new.2_3_0.sheets.title": "Yaprak başına sayfa",
+ "whats_new.2_3_0.sheets.text": "Her yaprağa 2, 4, 6, 8 veya 9 PDF sayfası yerleştirin. Sayfalar vektör olarak kalır, metin net görünür.",
+ "whats_new.2_3_0.fields.title": "Yalnızca kullandığınız biçimler",
+ "whats_new.2_3_0.fields.text": "CAD, 3B veya yazı tipleri gibi çalışmadığınız alanları Ayarlar → Alanlar bölümünden kapatın. Biçimleri halkadan ve listelerden kalkar.",
+ "whats_new.2_3_0.translate.title": "Bilgisayarınızda altyazı ve çeviri",
+ "whats_new.2_3_0.translate.text": "Videodan SRT altyazı oluşturun; belgeleri, e-kitapları ve altyazıları yerel bir yapay zekâyla çevirin. Hiçbir şey bilgisayarınızdan çıkmaz.",
+ "whats_new.2_3_0.more.title": "Ve biraz daha fazlası",
+ "whats_new.2_3_0.more.text": "Klasöre kopyalama veya taşıma, perspektif kırpma, baskı için CMYK dosyaları, Krita ve Minecraft dosyaları, ayrıca İtalyanca, Endonezce ve Vietnamca.",
"preset.extract_motion.donut": "Live Photo videosunu çıkar",
"presets.copy_to_folder": "Klasöre kopyala",
"presets.copy_hint": "Dosyaları ve klasörleri dönüştürmeden kopyalar. Orijinaller yerinde kalır; var olan adlara bir numara eklenir.",
diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json
index e90ce1e5b..f619e0d39 100644
--- a/src/Filee.App/Assets/i18n/vi.json
+++ b/src/Filee.App/Assets/i18n/vi.json
@@ -1397,6 +1397,18 @@
"whats_new.2_0_0.languages.text": "Tiếng Tây Ban Nha, tiếng Hindi, tiếng Nhật, tiếng Nga, tiếng Ả Rập và tiếng Bồ Đào Nha (Brazil) cùng góp mặt với tiếng Anh, tiếng Hàn, tiếng Trung và tiếng Ba Lan.",
"whats_new.2_0_0.more.title": "Và thêm một chút nữa",
"whats_new.2_0_0.more.text": "Đặt lại riêng một vòng tròn về mặc định, tìm kiếm toàn bộ lịch sử và chuyển đổi tài liệu tiếng Gujarat, tiếng Hindi hoặc tiếng Ả Rập qua LibreOffice.",
+ "whats_new.2_3_0.annotate.title": "Chú thích trên ảnh",
+ "whats_new.2_3_0.annotate.text": "Vẽ mũi tên, khung, tô sáng, chữ và số thứ tự ngay trên ảnh. Thêm Chú thích vào một vòng tròn trong Cài đặt → Thanh công cụ vòng tròn.",
+ "whats_new.2_3_0.crop_video.title": "Cắt khung video",
+ "whats_new.2_3_0.crop_video.text": "Gỡ viền đen được tự động tìm thấy, hoặc cắt theo tỉ lệ 16:9, 9:16, 4:3, 1:1 hay 21:9. Video HDR vẫn giữ HDR.",
+ "whats_new.2_3_0.sheets.title": "Số trang mỗi tờ",
+ "whats_new.2_3_0.sheets.text": "Đặt 2, 4, 6, 8 hoặc 9 trang PDF lên mỗi tờ. Các trang vẫn là vector nên chữ vẫn sắc nét.",
+ "whats_new.2_3_0.fields.title": "Chỉ những định dạng bạn dùng",
+ "whats_new.2_3_0.fields.text": "Tắt các lĩnh vực bạn không làm, như CAD, 3D hay phông chữ, trong Cài đặt → Lĩnh vực. Định dạng của chúng sẽ biến mất khỏi vòng tròn và các danh sách.",
+ "whats_new.2_3_0.translate.title": "Phụ đề và bản dịch ngay trên máy tính",
+ "whats_new.2_3_0.translate.text": "Tạo phụ đề SRT từ video, và dịch tài liệu, sách điện tử và phụ đề bằng AI cục bộ. Không có gì rời khỏi máy tính của bạn.",
+ "whats_new.2_3_0.more.title": "Và thêm một chút nữa",
+ "whats_new.2_3_0.more.text": "Sao chép hoặc di chuyển vào thư mục, cắt phối cảnh, tệp CMYK để in, tệp Krita và Minecraft, cùng tiếng Ý, tiếng Indonesia và tiếng Việt.",
"preset.extract_motion.donut": "Trích xuất video",
"presets.copy_to_folder": "Sao chép vào thư mục",
"presets.copy_hint": "Sao chép tệp và thư mục mà không chuyển đổi. Bản gốc được giữ tại chỗ; tên đã tồn tại sẽ được thêm số.",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index 7b7ff0f73..60a442c7f 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -1397,6 +1397,18 @@
"whats_new.2_0_0.languages.text": "在英语、韩语、中文和波兰语之外,新增西班牙语、印地语、日语、俄语、阿拉伯语和巴西葡萄牙语。",
"whats_new.2_0_0.more.title": "还有更多",
"whats_new.2_0_0.more.text": "可以只把一个环形工具栏恢复默认、搜索全部历史记录,并通过 LibreOffice 转换古吉拉特语、印地语或阿拉伯语文档。",
+ "whats_new.2_3_0.annotate.title": "给图片加批注",
+ "whats_new.2_3_0.annotate.text": "直接在图片上画箭头、方框、高亮、文字和编号。可在“设置 → 环形工具栏”中把批注加到圆环上。",
+ "whats_new.2_3_0.crop_video.title": "裁剪视频画面",
+ "whats_new.2_3_0.crop_video.text": "自动找出并去除黑边,或裁剪为 16:9、9:16、4:3、1:1 或 21:9。HDR 视频仍保持 HDR。",
+ "whats_new.2_3_0.sheets.title": "多页合一",
+ "whats_new.2_3_0.sheets.text": "每张纸放 2、4、6、8 或 9 页 PDF。页面保持矢量,文字依然清晰。",
+ "whats_new.2_3_0.fields.title": "只显示您用的格式",
+ "whats_new.2_3_0.fields.text": "在“设置 → 领域”中关闭不用的领域,比如 CAD、3D 或字体,它们的格式就会从圆环和列表中消失。",
+ "whats_new.2_3_0.translate.title": "在本机生成字幕和翻译",
+ "whats_new.2_3_0.translate.text": "从视频生成 SRT 字幕,并用本机 AI 翻译文档、电子书和字幕。文件不会离开您的电脑。",
+ "whats_new.2_3_0.more.title": "还有更多",
+ "whats_new.2_3_0.more.text": "复制或移动到文件夹、透视校正裁剪、印刷用 CMYK 文件、Krita 和 Minecraft 文件,以及意大利语、印度尼西亚语和越南语。",
"preset.extract_motion.donut": "提取实况/动态照片视频",
"presets.copy_to_folder": "复制到文件夹",
"presets.copy_hint": "直接复制文件和文件夹,不转换格式。原件保留,重名时自动编号。",
diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json
index b0eadb8e3..5357f4cff 100644
--- a/src/Filee.App/Assets/i18n/zh-TW.json
+++ b/src/Filee.App/Assets/i18n/zh-TW.json
@@ -1397,6 +1397,18 @@
"whats_new.2_0_0.languages.text": "在英語、韓語、中文和波蘭語之外,新增西班牙語、印地語、日語、俄語、阿拉伯語和巴西葡萄牙語。",
"whats_new.2_0_0.more.title": "還有更多",
"whats_new.2_0_0.more.text": "可以只把一個環形工具列還原為初始設定、搜尋全部歷史記錄,並透過 LibreOffice 轉換古吉拉特語、印地語或阿拉伯語文件。",
+ "whats_new.2_3_0.annotate.title": "為圖片加上註解",
+ "whats_new.2_3_0.annotate.text": "直接在圖片上畫箭頭、方框、螢光標示、文字和編號。可在「設定 → 環形工具列」中把註解加到圓環上。",
+ "whats_new.2_3_0.crop_video.title": "裁剪影片畫面",
+ "whats_new.2_3_0.crop_video.text": "自動找出並移除黑邊,或裁剪為 16:9、9:16、4:3、1:1 或 21:9。HDR 影片仍維持 HDR。",
+ "whats_new.2_3_0.sheets.title": "多頁合一",
+ "whats_new.2_3_0.sheets.text": "每張紙放 2、4、6、8 或 9 頁 PDF。頁面保持向量,文字依然清晰。",
+ "whats_new.2_3_0.fields.title": "只顯示您用的格式",
+ "whats_new.2_3_0.fields.text": "在「設定 → 領域」中關閉不用的領域,例如 CAD、3D 或字型,它們的格式就會從圓環和清單中消失。",
+ "whats_new.2_3_0.translate.title": "在本機產生字幕與翻譯",
+ "whats_new.2_3_0.translate.text": "從影片產生 SRT 字幕,並用本機 AI 翻譯文件、電子書和字幕。檔案不會離開您的電腦。",
+ "whats_new.2_3_0.more.title": "還有更多",
+ "whats_new.2_3_0.more.text": "複製或移動到資料夾、透視校正裁剪、印刷用 CMYK 檔案、Krita 和 Minecraft 檔案,以及義大利語、印尼語和越南語。",
"preset.extract_motion.donut": "擷取原況/動態相片影片",
"presets.copy_to_folder": "複製到資料夾",
"presets.copy_hint": "不轉換,直接複製檔案和資料夾。原始檔案留在原處;同名時會加上編號。",
diff --git a/tests/Filee.App.Tests/WhatsNewMedia.cs b/tests/Filee.App.Tests/WhatsNewMedia.cs
index 2b94cb430..1f4f7d777 100644
--- a/tests/Filee.App.Tests/WhatsNewMedia.cs
+++ b/tests/Filee.App.Tests/WhatsNewMedia.cs
@@ -44,7 +44,7 @@ public class WhatsNewMedia
/// The light theme's PageBrush: the canvas behind every clip.
private const string Page = "#F6F5F1";
- /// Where the 2.0.0 slides live in the repository.
+ /// Where the slides of live in the repository.
internal static string Folder(string version) =>
Path.Combine(RepositoryRoot, "src", "Filee.App", "Assets", "WhatsNew", version);
@@ -98,6 +98,33 @@ public void Generate_2_0_0()
}
}
+ [AvaloniaFact(Explicit = true)]
+ public void Generate_2_3_0()
+ {
+ TestServices.EnsureInitialized("en");
+ var folder = Folder("2.3.0");
+ var theme = AppHost.Get();
+ Motion.Set(true);
+ var culture = System.Globalization.CultureInfo.CurrentCulture;
+ System.Globalization.CultureInfo.CurrentCulture = System.Globalization.CultureInfo.GetCultureInfo("en-US");
+ try
+ {
+ theme.Apply(new ThemeSettings { ReduceMotion = false, Mode = ThemeMode.Light }, "en");
+ Write(folder, "annotate.webp", Annotate);
+ Write(folder, "crop_video.webp", CropVideo);
+ Write(folder, "sheets.webp", Sheets);
+ Write(folder, "fields.webp", Fields);
+ Write(folder, "translate.webp", Translate);
+ Write(folder, "more.webp", AndMore);
+ }
+ finally
+ {
+ System.Globalization.CultureInfo.CurrentCulture = culture;
+ theme.Apply(new ThemeSettings(), "en");
+ TestServices.EnsureInitialized("en");
+ }
+ }
+
// ───────────────────────── Scenes ─────────────────────────
///
@@ -636,6 +663,1000 @@ private static Clip More(string version)
return clip;
}
+ // ───────────────────────── Scenes of 2.3.0 ─────────────────────────
+
+ ///
+ /// Annotate: a web page screenshot marked up with the tools of the bar, each picked with the pointer: a
+ /// highlight over the title, a box round the button, an arrow to it, a label typed in place and two numbers.
+ ///
+ private static Clip Annotate()
+ {
+ var clip = new Clip { Quality = 74 };
+ var folder = Directory.CreateTempSubdirectory("filee-whatsnew-annotate-").FullName;
+ var store = AppHost.Get();
+ var tool = store.FindPreset(BuiltInData.AnnotateId) ?? BuiltInData.AnnotatePresets().Single();
+ AnnotateTool.Remember(tool, AnnotateTool.Colors[0], AnnotationWidth.Medium);
+ AnnotateWindow? window = null;
+ try
+ {
+ var picture = Path.Combine(folder, "lisbon.png");
+ DrawWebPage(picture);
+ var vm = new AnnotateViewModel([picture], tool, AppHost.Get(), store, _ => BuiltInData.SameAsSource, (_, _) => { });
+ window = new AnnotateWindow { DataContext = vm, Width = 1024, Height = 600 };
+ window.Show();
+ WaitFor(() => vm.PreviewImage is not null && !vm.IsBusy);
+ Pump();
+
+ var bounds = window.AnnotationCanvas.CalculateImageBounds();
+ Avalonia.Point On(double x, double y) =>
+ window.AnnotationCanvas.TranslatePoint(new Avalonia.Point(bounds.X + x * bounds.Width, bounds.Y + y * bounds.Height), window)!.Value;
+ Avalonia.Point Middle(Control control) =>
+ control.TranslatePoint(new Avalonia.Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)!.Value;
+ Control Named(string name) => window.GetVisualDescendants().OfType().First(c => c.Name == name);
+ Avalonia.Rect InWindow(Control control) => new(control.TranslatePoint(default, window)!.Value, control.Bounds.Size);
+
+ // The picture and the bar under it, closer than the whole window shows them (the file name above and the
+ // keys below are left out).
+ var shown = new Avalonia.Rect(On(0, 0), On(1, 1)).Union(InWindow(Named("SelectTool"))).Union(InWindow(Named("SaveButton"))).Inflate(14);
+ var height = Math.Max(shown.Height, shown.Width * CanvasHeight / CanvasWidth);
+ var region = new Avalonia.Rect(shown.Center.X - height * CanvasWidth / CanvasHeight / 2, shown.Center.Y - height / 2,
+ height * CanvasWidth / CanvasHeight, height);
+ var scene = new Scene(window, clip) { FrameLength = 1 / 15.0, Region = region };
+ void Pick(string name)
+ {
+ scene.MoveTo(Middle(Named(name)) + new Avalonia.Vector(3, 4), 0.45);
+ scene.Hold(0.1);
+ scene.Click();
+ scene.Hold(0.15);
+ }
+ void Swatch(int index)
+ {
+ var swatch = window.GetVisualDescendants().OfType().Where(b => b.Classes.Contains("swatch")).ElementAt(index);
+ scene.MoveTo(Middle(swatch) + new Avalonia.Vector(2, 3), 0.35);
+ scene.Click();
+ scene.Hold(0.15);
+ }
+ void Drag((double X, double Y) from, (double X, double Y) to, double seconds)
+ {
+ scene.MoveTo(On(from.X, from.Y), 0.4);
+ scene.Press();
+ scene.MoveTo(On(to.X, to.Y), seconds);
+ scene.Release();
+ scene.Hold(0.25);
+ }
+
+ // Frame 0: the picture open in the window, nothing drawn yet.
+ scene.Place(On(0.45, 0.42));
+ scene.Hold(0.6);
+
+ // A highlight over the title, in yellow.
+ Pick("HighlightTool");
+ Swatch(2);
+ Drag((0.565, 0.16), (0.905, 0.225), 0.6);
+
+ // A box round the button, in red.
+ Pick("BoxTool");
+ Swatch(0);
+ Drag((0.558, 0.505), (0.776, 0.632), 0.6);
+
+ // An arrow pointing at it.
+ Pick("ArrowTool");
+ Drag((0.375, 0.775), (0.545, 0.62), 0.5);
+
+ // A label typed where it goes.
+ Pick("TextTool");
+ scene.MoveTo(On(0.2, 0.81), 0.4);
+ scene.Click();
+ WaitFor(() => window.Editor.IsVisible);
+ scene.Hold(0.2);
+ foreach (var letter in "Book this one")
+ {
+ window.KeyTextInput(letter.ToString());
+ scene.Tick();
+ if (letter == ' ')
+ scene.Tick();
+ }
+ scene.Hold(0.3);
+ scene.Key("Enter", () => window.KeyPress(Key.Enter, RawInputModifiers.None, PhysicalKey.Enter, null));
+ window.KeyRelease(Key.Enter, RawInputModifiers.None, PhysicalKey.Enter, null);
+ scene.Hold(0.3);
+ scene.HideKey();
+
+ // Numbers count up: 1 on the picture, 2 on the price.
+ Pick("NumberTool");
+ foreach (var (x, y) in new[] { (0.07, 0.19), (0.553, 0.405) })
+ {
+ scene.MoveTo(On(x, y), 0.45);
+ scene.Click();
+ scene.Hold(0.3);
+ }
+
+ scene.MoveTo(On(0.47, 0.5), 0.5);
+ scene.Hold(0.2);
+ clip.MarkPoster();
+ scene.Hold(2.0);
+ Assert.Equal(6, vm.Marks.Count);
+ }
+ finally
+ {
+ window?.Close();
+ try
+ {
+ Directory.Delete(folder, recursive: true);
+ }
+ catch (IOException) { }
+ }
+ return clip;
+ }
+
+ ///
+ /// Crop video (#830): a film with black bars, dragged onto the video donut; Tab turns it to its back face, the
+ /// "Crop video" slice fans out its ring and the film is dropped on "Auto" (remove black bars); then the frame loses
+ /// its bars.
+ ///
+ private static Clip CropVideo()
+ {
+ var clip = new Clip { Quality = 76 };
+ var folder = Directory.CreateTempSubdirectory("filee-whatsnew-video-").FullName;
+ try
+ {
+ // A 2.39:1 film in a 16:9 frame.
+ var still = Path.Combine(folder, "still.jpg");
+ DrawLandscape(still, 2);
+ using var picture = new MagickImage(still);
+ picture.Crop(new MagickGeometry(0, 140, 1600, 670));
+ picture.ResetPage();
+ picture.Resize(new MagickGeometry(544, 228) { IgnoreAspectRatio = true });
+ var bars = (306 - picture.Height) / 2.0; // 544 × 306 is 16:9
+
+ // Frame 0: the film with its bars.
+ Film(clip, picture, bars, 0, 1100);
+
+ using (var drag = new DonutDrag(clip, [@"C:\Videos\trailer.mp4"]))
+ {
+ drag.Open(drag.Slice(1, 0.55));
+ drag.Flip();
+ var group = drag.Index("crop-video");
+ drag.Scene.MoveTo(drag.Slice(group, 0.6), 0.4);
+ drag.Scene.MoveTo(drag.Slice(group, 0.93), 0.25);
+ drag.Scene.Hold(0.3);
+ drag.Scene.MoveTo(drag.Sub(group, 0) + new Avalonia.Vector(-2, 10), 0.5);
+ drag.Scene.Hold(0.5);
+ Assert.Equal(new DonutHit(DonutHitKind.SubSegment, group, 0), drag.Hit);
+ // Shown with reduced motion: the ring of ratios, the drop on Auto.
+ clip.MarkPoster();
+ drag.Scene.Hold(0.6);
+ drag.Drop(0.8, 0.6);
+ }
+
+ // The bars found are marked and cut away.
+ Film(clip, picture, bars, 0, 300);
+ for (var t = 1; t <= 6; t++)
+ Film(clip, picture, bars, t / 6.0, 50);
+ clip.Hold(400);
+ for (var t = 1; t <= 14; t++)
+ Film(clip, picture, bars * (1 - Scene.EaseInOut(t / 14.0)), 1 - t / 14.0, 50);
+ clip.Hold(2000);
+ }
+ finally
+ {
+ try
+ {
+ Directory.Delete(folder, recursive: true);
+ }
+ catch (IOException) { }
+ }
+ return clip;
+
+ // The frame of a video with a play bar under it: the picture with black bars of above and below, and a
+ // dashed line round the picture (0 to 1) visible.
+ static void Film(Clip clip, MagickImage picture, double bars, double outline, int milliseconds)
+ {
+ var (width, height) = ((double)picture.Width, picture.Height + 2 * bars);
+ var (left, top) = ((CanvasWidth - width) / 2, (CanvasHeight - height) / 2 - 12);
+ var canvas = new MagickImage(new MagickColor(Page), CanvasWidth, CanvasHeight);
+ new Drawables()
+ .FillColor(new MagickColor("#00000014")).RoundRectangle(left - 2, top + 3, left + width + 2, top + height + 8, 12, 12)
+ .FillColor(new MagickColor("#0B0B0C")).RoundRectangle(left, top, left + width, top + height, 10, 10)
+ .Draw(canvas);
+ canvas.Composite(picture, (int)Math.Round(left), (int)Math.Round(top + bars), CompositeOperator.Over);
+ var barTop = top + height + 22;
+ var drawing = new Drawables()
+ .FillColor(new MagickColor("#1F1E1D")).Polygon(new PointD(left + 4, barTop - 7), new PointD(left + 4, barTop + 7), new PointD(left + 16, barTop))
+ .FillColor(new MagickColor("#DAD7CF")).RoundRectangle(left + 32, barTop - 3, left + width, barTop + 3, 3, 3)
+ .FillColor(new MagickColor("#7F77DD")).RoundRectangle(left + 32, barTop - 3, left + 220, barTop + 3, 3, 3)
+ .FillColor(new MagickColor("#7F77DD")).Circle(left + 220, barTop, left + 227, barTop);
+ if (outline > 0)
+ drawing
+ .FillColor(MagickColors.Transparent).StrokeColor(new MagickColor(255, 255, 255, (byte)Math.Round(255 * outline)))
+ .StrokeWidth(2).StrokeDashArray(9, 6)
+ .Rectangle(left + 1.5, top + bars + 1.5, left + width - 1.5, top + bars + picture.Height - 1.5);
+ drawing.Draw(canvas);
+ clip.Add(canvas, milliseconds);
+ }
+ }
+
+ ///
+ /// Pages per sheet (#737): an eight-page PDF dragged onto the PDF donut, the "Pages per sheet" slice fanning out
+ /// 2, 4, 6, 8 and 9, the drop on 4; then the pages going onto their two sheets. The sheets are the PdfSheets output
+ /// for that PDF, rendered; the pages fly to where it put them.
+ ///
+ private static Clip Sheets()
+ {
+ var clip = new Clip { Quality = 78 };
+ var folder = Directory.CreateTempSubdirectory("filee-whatsnew-sheets-").FullName;
+ // Pages per sheet is in the palette, on no donut: put on the PDF donut's front for the clip.
+ var pdf = AppHost.Get().Profiles.Single(p => p.Id == "pdf");
+ var front = pdf.PresetIds.ToList();
+ pdf.PresetIds[pdf.PresetIds.IndexOf("to-hwp")] = "pdf-nup";
+ var pages = new List();
+ var sheets = new List();
+ try
+ {
+ var source = Path.Combine(folder, "chapters.pdf");
+ var output = Path.Combine(folder, "chapters_4.pdf");
+ DrawDocument(source, folder);
+ var options = BuiltInData.CreatePresets().Single(p => p.Id == "pdf-nup-4").Pdf;
+ Filee.Engines.Pdf.PdfSheets.Write(source, output, options, null, CancellationToken.None);
+ var input = Filee.Engines.Pdf.PdfPreviewDocument.Open(source);
+ var result = Filee.Engines.Pdf.PdfPreviewDocument.Open(output);
+ Assert.Equal((8, 2), (input.PageCount, result.PageCount));
+ pages.AddRange(Enumerable.Range(1, 8).Select(n => new MagickImage(input.RenderPage(n, 480).Png)));
+ sheets.AddRange(Enumerable.Range(1, 2).Select(n => new MagickImage(result.RenderPage(n, 720).Png)));
+
+ // Where everything goes on the canvas: the pages in a row, the two sheets side by side, and in the sheets
+ // the cells PdfSheets drew the pages in.
+ var (pageWidth, pageHeight) = (84.0, 84.0 * pages[0].Height / pages[0].Width);
+ var row = Enumerable.Range(0, 8).Select(k => new Avalonia.Rect(
+ (CanvasWidth - (8 * pageWidth + 7 * 9)) / 2 + k * (pageWidth + 9), (CanvasHeight - pageHeight) / 2, pageWidth, pageHeight)).ToList();
+ var sheetHeight = 356.0;
+ var sheetWidth = sheetHeight * sheets[0].Width / sheets[0].Height;
+ var sheetAt = Enumerable.Range(0, 2).Select(k => new Avalonia.Rect(
+ (CanvasWidth - (2 * sheetWidth + 44)) / 2 + k * (sheetWidth + 44), (CanvasHeight - sheetHeight) / 2, sheetWidth, sheetHeight)).ToList();
+ var a4 = new PdfSharp.Drawing.XSize(595.28, 841.89);
+ var cells = Filee.Engines.Pdf.PdfSheets.Cells(a4, options, 4);
+ var targets = Enumerable.Range(0, 8).Select(k =>
+ {
+ var (sheet, cell) = (sheetAt[k / 4], cells[k % 4]);
+ var scale = sheet.Width / a4.Width;
+ var fit = Math.Min(cell.Width / pageWidth, cell.Height / pageHeight) * scale;
+ var (w, h) = (pageWidth * fit, pageHeight * fit);
+ return new Avalonia.Rect(sheet.X + (cell.X + cell.Width / 2) * scale - w / 2, sheet.Y + (cell.Y + cell.Height / 2) * scale - h / 2, w, h);
+ }).ToList();
+
+ // Frame 0: the eight pages.
+ clip.Add(Layout(pages, row, null, 0), 1100);
+
+ using (var drag = new DonutDrag(clip, [@"C:\Documents\chapters.pdf"]))
+ {
+ var group = drag.Index("pdf-nup");
+ drag.Open(drag.Slice(group, 0.45));
+ drag.Scene.MoveTo(drag.Slice(group, 0.93), 0.3);
+ drag.Scene.Hold(0.3);
+ drag.Scene.MoveTo(drag.Sub(group, 1) + new Avalonia.Vector(0, 6), 0.5);
+ drag.Scene.Hold(0.9);
+ Assert.Equal(new DonutHit(DonutHitKind.SubSegment, group, 1), drag.Hit);
+ drag.Drop(0.8, 0.6);
+ }
+
+ // The pages fly onto the sheets, one after another, as the sheets come in under them.
+ clip.Add(Layout(pages, row, null, 0), 400);
+ const int frames = 22;
+ for (var f = 1; f <= frames; f++)
+ {
+ var places = row.Select((from, k) =>
+ {
+ var u = Scene.EaseInOut(Math.Clamp((f / (double)frames * 1.6) - k * 0.075, 0, 1));
+ var to = targets[k];
+ return new Avalonia.Rect(from.X + (to.X - from.X) * u, from.Y + (to.Y - from.Y) * u,
+ from.Width + (to.Width - from.Width) * u, from.Height + (to.Height - from.Height) * u);
+ }).ToList();
+ clip.Add(Layout(pages, places, sheetAt, Math.Min(1, f / (frames * 0.4))), 50);
+ }
+ // The sheets as PdfSheets wrote them.
+ var final = new MagickImage(new MagickColor(Page), CanvasWidth, CanvasHeight);
+ for (var k = 0; k < 2; k++)
+ {
+ DrawPaper(final, sheetAt[k], 1);
+ using var sheet = (MagickImage)sheets[k].Clone();
+ sheet.FilterType = FilterType.Lanczos;
+ sheet.Resize((uint)Math.Round(sheetAt[k].Width), (uint)Math.Round(sheetAt[k].Height));
+ final.Composite(sheet, (int)Math.Round(sheetAt[k].X), (int)Math.Round(sheetAt[k].Y), CompositeOperator.Over);
+ }
+ clip.Add(final, 2400);
+ // Shown with reduced motion: the two sheets.
+ clip.MarkPoster();
+ }
+ finally
+ {
+ pdf.PresetIds = front;
+ foreach (var image in pages.Concat(sheets))
+ image.Dispose();
+ try
+ {
+ Directory.Delete(folder, recursive: true);
+ }
+ catch (IOException) { }
+ }
+ return clip;
+
+ // The pages at , over the sheets at ( 0 to 1 of the way in).
+ static MagickImage Layout(List pages, List places, List? sheetAt, double paper)
+ {
+ var canvas = new MagickImage(new MagickColor(Page), CanvasWidth, CanvasHeight);
+ if (sheetAt is not null && paper > 0)
+ foreach (var sheet in sheetAt)
+ DrawPaper(canvas, sheet, paper);
+ for (var k = 0; k < pages.Count; k++)
+ {
+ var at = places[k];
+ using var page = (MagickImage)pages[k].Clone();
+ page.FilterType = FilterType.Lanczos;
+ page.Resize((uint)Math.Round(at.Width), (uint)Math.Round(at.Height));
+ if (sheetAt is null)
+ new Drawables().FillColor(new MagickColor("#0000001A"))
+ .RoundRectangle(at.X - 1, at.Y + 1, at.Right + 1, at.Bottom + 3, 3, 3).Draw(canvas);
+ canvas.Composite(page, (int)Math.Round(at.X), (int)Math.Round(at.Y), CompositeOperator.Over);
+ }
+ return canvas;
+ }
+
+ // A sheet of paper with a soft shadow, 0 to 1.
+ static void DrawPaper(MagickImage canvas, Avalonia.Rect sheet, double opacity)
+ {
+ var alpha = (byte)Math.Round(255 * opacity);
+ new Drawables()
+ .FillColor(new MagickColor(0, 0, 0, (byte)Math.Round(0.08 * alpha))).RoundRectangle(sheet.X - 2, sheet.Y + 2, sheet.Right + 2, sheet.Bottom + 6, 6, 6)
+ .FillColor(new MagickColor(255, 255, 255, alpha)).Rectangle(sheet.X, sheet.Y, sheet.Right, sheet.Bottom)
+ .Draw(canvas);
+ }
+ }
+
+ ///
+ /// Settings → Fields (#806): "Design & print" and then "Games" switched off on the page, and the pictures donut
+ /// beside it opening again without their slices (PSD and EPS, then DDS). The page gets real pointer input; the donut
+ /// is the one the next drop opens, played on the clip's clock.
+ ///
+ private static Clip Fields()
+ {
+ var clip = new Clip { Quality = 78 };
+ var store = AppHost.Get();
+ var loc = AppHost.Get();
+ var turnedOff = store.Settings.TurnedOffFields.ToList();
+ var images = store.Profiles.Single(p => p.Id == "images");
+ var front = images.PresetIds.ToList();
+ // Someone who works in design and games: PSD, EPS and DDS on the pictures donut, those fields on.
+ Preset[] added =
+ [
+ new() { Id = "whatsnew-psd", Name = "PSD", TargetFormat = "psd" },
+ new() { Id = "whatsnew-eps", Name = "EPS", TargetFormat = "eps" },
+ new() { Id = "whatsnew-dds", Name = "DDS", TargetFormat = "dds" },
+ ];
+ store.Presets.AddRange(added);
+ images.PresetIds = ["to-png", "to-jpg", "to-webp", "whatsnew-psd", "whatsnew-eps", "whatsnew-dds", "to-pdf", "trace-svg"];
+ store.Settings.TurnedOffFields = Filee.Core.Formats.FormatFields.All
+ .Where(f => f.CanTurnOff && !f.OnByDefault && f.Id is not ("design" or "game")).Select(f => f.Id).ToList();
+ string[] files = [@"C:\Pictures\poster.png"];
+ var page = new Window
+ {
+ Width = 470,
+ Height = 1100,
+ Background = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse(Page)),
+ Content = new Border { Padding = new Avalonia.Thickness(24), Child = new FieldsPage { DataContext = new FieldsPageViewModel(store, loc) } },
+ };
+ var (donutWindow, vm) = OpenDonut(files, clickMode: true);
+ var donut = donutWindow.DonutControl;
+ try
+ {
+ page.Show();
+ Pump();
+ ToolTip.SetServiceEnabled(page, false);
+ Assert.Equal(8, vm.Items.Count);
+ var switches = page.GetVisualDescendants().OfType().ToList();
+ ToggleSwitch Switch(string id) => switches.Single(s => Avalonia.Automation.AutomationProperties.GetName(s) == loc[$"fields.name.{id}"]);
+ Avalonia.Rect InPage(Control control) => new(control.TranslatePoint(default, page)!.Value, control.Bounds.Size);
+
+ // The card from its top down to the Developer row.
+ var card = InPage(page.GetVisualDescendants().OfType().First(x => x.Classes.Contains("card")));
+ var developer = Switch("developer").FindAncestorOfType()!.GetVisualDescendants().OfType().Max(t => InPage(t).Bottom);
+ var region = new Avalonia.Rect(card.X - 8, card.Y - 8, card.Width + 16, developer - card.Y + 18);
+ const double donutSide = 344;
+ var pageScale = Math.Min(1, Math.Min(404 / region.Width, (CanvasHeight - 12) / region.Height));
+ var pageAt = new Avalonia.Point(18, (CanvasHeight - region.Height * pageScale) / 2);
+ var design = InPage(Switch("design"));
+ var pointer = new Avalonia.Point(design.X - 120, design.Y + 40);
+ var pressed = false;
+
+ MagickImage Frame()
+ {
+ var canvas = new MagickImage(new MagickColor(Page), CanvasWidth, CanvasHeight);
+ // Drawn directly: a frame of the render timer would also run the donut's frame loop.
+ using (var shot = Snapshot(page, direct: true))
+ {
+ shot.Crop(new MagickGeometry((int)region.X, (int)region.Y, (uint)region.Width, (uint)region.Height));
+ shot.ResetPage();
+ shot.FilterType = FilterType.Lanczos;
+ shot.Resize((uint)Math.Round(region.Width * pageScale), (uint)Math.Round(region.Height * pageScale));
+ canvas.Composite(shot, (int)pageAt.X, (int)Math.Round(pageAt.Y), CompositeOperator.Over);
+ }
+ using (var shot = Snapshot(donutWindow))
+ {
+ var scale = Math.Min(1, donutSide / shot.Width);
+ shot.FilterType = FilterType.Lanczos;
+ shot.Resize((uint)Math.Round(shot.Width * scale), (uint)Math.Round(shot.Height * scale));
+ canvas.Composite(shot, (int)(CanvasWidth - 12 - donutSide + (donutSide - shot.Width) / 2), (int)((CanvasHeight - shot.Height) / 2), CompositeOperator.Over);
+ }
+ DrawPointer(canvas, new PointD(pageAt.X + (pointer.X - region.X) * pageScale, pageAt.Y + (pointer.Y - region.Y) * pageScale), pressed);
+ return canvas;
+ }
+ // The render timer plays the switch's transition, but also the donut's own frame loop on wall-clock time: it
+ // runs only while the donut is at rest.
+ void Tick(double seconds = 0.05, bool renderTimer = true)
+ {
+ for (var t = 0.0; t < seconds - 1e-9; t += 0.05)
+ {
+ KeepFramesComing(donut);
+ donut.Advance(0.05);
+ KeepFramesComing(donut);
+ for (var i = 0; i < 3; i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ if (renderTimer)
+ AvaloniaHeadlessPlatform.ForceRenderTimerTick();
+ }
+ clip.Add(Frame(), 50);
+ }
+ }
+ void MoveTo(Avalonia.Point to, double seconds)
+ {
+ var from = pointer;
+ var frames = Math.Max(1, (int)Math.Round(seconds / 0.05));
+ var normal = new Avalonia.Vector(from.Y - to.Y, to.X - from.X) * 0.06;
+ for (var i = 1; i <= frames; i++)
+ {
+ var u = Scene.EaseInOut(i / (double)frames);
+ pointer = new Avalonia.Point(from.X + (to.X - from.X) * u, from.Y + (to.Y - from.Y) * u) + normal * Math.Sin(Math.PI * u);
+ page.MouseMove(pointer);
+ Tick();
+ }
+ }
+ // A switch clicked off, and the donut opening again without that field's slices.
+ void TurnOff(string id, int slicesLeft)
+ {
+ var toggle = Switch(id);
+ var at = InPage(toggle);
+ MoveTo(new Avalonia.Point(at.X + 14, at.Center.Y + 2), 0.6);
+ Tick(0.15);
+ page.MouseDown(pointer, MouseButton.Left);
+ pressed = true;
+ Tick();
+ page.MouseUp(pointer, MouseButton.Left);
+ pressed = false;
+ Assert.Contains(id, store.Settings.TurnedOffFields);
+ Tick(0.35);
+ vm.Load(files, clickMode: true);
+ Assert.Equal(slicesLeft, vm.Items.Count);
+ Dispatcher.UIThread.RunJobs(); // the donut takes the new slices before it opens
+ donut.PlayOpenAnimation();
+ Tick(1.2, renderTimer: false);
+ }
+
+ // Frame 0: Design & print and Games on, PSD, EPS and DDS on the donut.
+ page.MouseMove(pointer);
+ donut.FinishAnimations();
+ Settle(donut);
+ clip.Add(Frame(), 1300);
+ TurnOff("design", 6);
+ TurnOff("game", 5);
+ clip.MarkPoster();
+ clip.Hold(1600);
+ }
+ finally
+ {
+ page.Close();
+ donutWindow.Close();
+ store.Settings.TurnedOffFields = turnedOff;
+ images.PresetIds = front;
+ foreach (var preset in added)
+ store.Presets.Remove(preset);
+ }
+ return clip;
+ }
+
+ ///
+ /// Captions and translation with a local AI: a video dropped on the "Caption" slice, its SRT written
+ /// cue by cue, then the same cues translated with their timings kept. Whisper and Ollama can't run here, so the
+ /// cues are written out; the cards use the app's own styles (card, h2, caption).
+ ///
+ private static Clip Translate()
+ {
+ var clip = new Clip { Quality = 80 };
+ (string Time, string English, string Korean)[] cues =
+ [
+ ("00:00:01,200 --> 00:00:03,800", "Welcome back to the workshop.", "워크숍에 다시 오신 걸 환영해요."),
+ ("00:00:04,100 --> 00:00:07,000", "Today we turn old photos into prints.", "오늘은 오래된 사진을 인쇄해 볼게요."),
+ ("00:00:07,400 --> 00:00:10,200", "Let's start with the scanner.", "먼저 스캐너부터 시작해요."),
+ ];
+ var loc = AppHost.Get();
+ var blocks = new List<(Control Block, bool Translated)>();
+ Control Card(string name, bool translated)
+ {
+ var list = new StackPanel { Spacing = 12, Margin = new Avalonia.Thickness(0, 12, 0, 0) };
+ for (var i = 0; i < cues.Length; i++)
+ {
+ var block = new StackPanel { Spacing = 1 };
+ block.Children.Add(new TextBlock { Classes = { "caption" }, Text = $"{i + 1}" });
+ block.Children.Add(new TextBlock { Classes = { "caption" }, Text = cues[i].Time });
+ block.Children.Add(new TextBlock { FontSize = 16, TextWrapping = Avalonia.Media.TextWrapping.Wrap, Text = translated ? cues[i].Korean : cues[i].English });
+ list.Children.Add(block);
+ blocks.Add((block, translated));
+ }
+ return new Border
+ {
+ Classes = { "card" },
+ Padding = new Avalonia.Thickness(20, 16),
+ Width = 316,
+ Child = new StackPanel { Children = { new TextBlock { Classes = { "h2" }, Text = name }, list } },
+ };
+ }
+ var arrow = new StackPanel
+ {
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ Margin = new Avalonia.Thickness(14, 0),
+ Spacing = 6,
+ Children =
+ {
+ new Border
+ {
+ Background = (Avalonia.Media.IBrush)Avalonia.Application.Current!.FindResource("AccentBrush")!,
+ CornerRadius = new Avalonia.CornerRadius(12),
+ Padding = new Avalonia.Thickness(10, 4),
+ Child = new TextBlock { Text = loc["preset.translate"], Foreground = Avalonia.Media.Brushes.White, FontWeight = Avalonia.Media.FontWeight.SemiBold },
+ },
+ new TextBlock { Text = "→", FontSize = 26, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center },
+ },
+ };
+ var window = new Window
+ {
+ Width = CanvasWidth,
+ Height = CanvasHeight,
+ Background = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse(Page)),
+ Content = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ Children = { Card("interview.srt", false), arrow, Card("interview_ko.srt", true) },
+ },
+ };
+ try
+ {
+ window.Show();
+ Pump();
+ // Frame 0 (reduced motion), and the frame the clip comes back to: both files written.
+ clip.Add(Capture(window), 1400);
+
+ // As someone with Whisper installed sees it.
+ using (var drag = new DonutDrag(clip, [@"C:\Videos\interview.mp4"], vm => vm.Items = vm.Items.Select(Enabled).ToList()))
+ {
+ var caption = drag.Index("caption-video");
+ drag.Open(drag.Slice(caption, 0.6));
+ drag.Scene.Hold(0.8);
+ Assert.Equal(new DonutHit(DonutHitKind.Segment, caption), drag.Hit);
+ drag.Drop(0.8, 0.5);
+ }
+
+ // The captions come in cue by cue, then their translation; the arrow shows between them.
+ void Show(Func shown)
+ {
+ for (var i = 0; i < blocks.Count; i++)
+ {
+ var amount = shown(i % cues.Length, blocks[i].Translated);
+ blocks[i].Block.Opacity = amount;
+ blocks[i].Block.RenderTransform = new Avalonia.Media.TranslateTransform(0, 8 * (1 - amount));
+ }
+ Pump();
+ }
+ double Ease(double t) => Scene.EaseInOut(Math.Clamp(t, 0, 1));
+ arrow.Opacity = 0;
+ Show((_, _) => 0);
+ clip.Add(Capture(window), 300);
+ for (var f = 1; f <= 24; f++)
+ {
+ Show((cue, translated) => translated ? 0 : Ease(f / 6.0 - cue * 1.2));
+ clip.Add(Capture(window), 50);
+ }
+ clip.Hold(300);
+ for (var f = 1; f <= 6; f++)
+ {
+ arrow.Opacity = f / 6.0;
+ Pump();
+ clip.Add(Capture(window), 50);
+ }
+ for (var f = 1; f <= 24; f++)
+ {
+ Show((cue, translated) => translated ? Ease(f / 6.0 - cue * 1.2) : 1);
+ clip.Add(Capture(window), 50);
+ }
+ clip.Hold(2000);
+ }
+ finally
+ {
+ window.Close();
+ }
+ return clip;
+ }
+
+ ///
+ /// "And more": Move to folder and Copy to folder slices on the back of the pictures donut, the perspective crop
+ /// straightening a photographed page, the donut of a Krita file, and the Home page in Italian and in Indonesian.
+ ///
+ private static Clip AndMore()
+ {
+ var clip = new Clip { Quality = 80 };
+ var store = AppHost.Get();
+ var loc = AppHost.Get();
+ var images = store.Profiles.Single(p => p.Id == "images");
+ var back = images.BackPresetIds?.ToList();
+ Preset Folder(string id, string name, string path, bool move) => new()
+ {
+ Id = id,
+ Name = name,
+ Kind = PresetKind.CopyToFolder,
+ TargetFormat = Preset.CopyTarget,
+ Output = new OutputRule { Location = OutputLocation.CustomFolder, CustomFolder = path, MoveInsteadOfCopy = move },
+ };
+ Preset[] added =
+ [
+ Folder("whatsnew-projects", "Projects", @"C:\Projects", move: true),
+ Folder("whatsnew-references", "References", @"C:\References", move: false),
+ ];
+ store.Presets.AddRange(added);
+ images.BackPresetIds = ["tool-crop", "rotate", "remove-background", "whatsnew-projects", "whatsnew-references", "tool-image-edit", "zip-all"];
+ var folder = Directory.CreateTempSubdirectory("filee-whatsnew-more-").FullName;
+ var clock = System.Diagnostics.Stopwatch.GetTimestamp();
+ CropCanvas.Clock = () => clock;
+ try
+ {
+ // Frame 0: the folders on the back face, the pointer on Projects (Move to folder).
+ using (var donut = new DonutDrag(clip, [@"C:\Pictures\sketch.png"], clickMode: true, backFace: true))
+ {
+ donut.Donut.FinishAnimations();
+ var (projects, references) = (donut.Index("whatsnew-projects"), donut.Index("whatsnew-references"));
+ donut.Scene.Place(donut.Slice(projects, 0.9));
+ donut.Aim();
+ Settle(donut.Donut);
+ donut.Scene.Tick();
+ clip.Hold(1000);
+ donut.Scene.MoveTo(donut.Slice(references, 0.9), 0.45);
+ donut.Scene.Hold(1.2);
+ }
+
+ // Perspective crop: the corners go onto a page photographed at an angle.
+ var photo = Path.Combine(folder, "notes.jpg");
+ var page = DrawTiltedPage(photo);
+ var preset = BuiltInData.CreatePresets().Single(p => p.Id == "tool-crop");
+ var vm = new CropOverlayViewModel([photo], preset, loc, store);
+ var window = new CropOverlayWindow
+ {
+ DataContext = vm,
+ Width = 889,
+ Height = 500,
+ Background = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse(Page)),
+ };
+ try
+ {
+ window.Show();
+ WaitFor(() => vm.PreviewImage is not null && !vm.IsBusy);
+ window.Classes.Set("reduced-motion", true);
+ vm.IsPerspective = true;
+ Pump();
+ var canvas = window.CropCanvas;
+ Assert.True(canvas.IsPerspective);
+ clip.Add(Capture(window), 500);
+ var from = canvas.NormalizedCorners;
+ static Avalonia.Point Toward(Avalonia.Point a, Avalonia.Point b, double u) => new(a.X + (b.X - a.X) * u, a.Y + (b.Y - a.Y) * u);
+ for (var f = 1; f <= 12; f++)
+ {
+ var u = Scene.EaseInOut(f / 12.0);
+ canvas.NormalizedCorners = new CropQuad(Toward(from.TopLeft, page.TopLeft, u), Toward(from.TopRight, page.TopRight, u),
+ Toward(from.BottomRight, page.BottomRight, u), Toward(from.BottomLeft, page.BottomLeft, u));
+ clock += System.Diagnostics.Stopwatch.Frequency / 20;
+ Pump();
+ clip.Add(Capture(window), 50);
+ }
+ clip.Hold(1500);
+ }
+ finally
+ {
+ window.Close();
+ }
+
+ // A new format: a Krita picture.
+ var (krita, kritaVm) = OpenDonut([@"C:\Art\castle.kra"], clickMode: true);
+ try
+ {
+ krita.DonutControl.PlayOpenAnimation();
+ Run(clip, krita, 0.4);
+ Highlight(krita, kritaVm, 0);
+ clip.Add(Capture(krita), 1400);
+ }
+ finally
+ {
+ krita.Close();
+ }
+
+ // Two new languages.
+ foreach (var language in new[] { "it", "id" })
+ {
+ TestServices.EnsureInitialized(language);
+ AppHost.Get().Apply(new ThemeSettings { ReduceMotion = false, Mode = ThemeMode.Light }, language);
+ var main = new MainWindowViewModel(AppHost.Services, AppHost.Get(), AppHost.Get(), AppHost.Get());
+ main.Navigate("home");
+ var home = new MainWindow { DataContext = main, Width = 960, Height = 540 };
+ home.Show();
+ Pump();
+ clip.Add(Capture(home, "2.3.0"), 1400);
+ home.Close();
+ main.Dispose();
+ }
+ }
+ finally
+ {
+ CropCanvas.Clock = System.Diagnostics.Stopwatch.GetTimestamp;
+ TestServices.EnsureInitialized("en");
+ AppHost.Get().Apply(new ThemeSettings { ReduceMotion = false, Mode = ThemeMode.Light }, "en");
+ images.BackPresetIds = back;
+ foreach (var preset in added)
+ store.Presets.Remove(preset);
+ try
+ {
+ Directory.Delete(folder, recursive: true);
+ }
+ catch (IOException) { }
+ }
+ return clip;
+ }
+
+ ///
+ /// A made-up photo of a page lying at an angle on a desk (1600 × 1000), with lines of writing that follow its tilt.
+ /// Returns where the page's corners are, in parts of the photo.
+ ///
+ private static CropQuad DrawTiltedPage(string path)
+ {
+ PointD[] corners = [new(360, 150), new(1230, 235), new(1300, 900), new(270, 830)];
+ PointD At(double u, double v)
+ {
+ var top = new PointD(corners[0].X + (corners[1].X - corners[0].X) * u, corners[0].Y + (corners[1].Y - corners[0].Y) * u);
+ var bottom = new PointD(corners[3].X + (corners[2].X - corners[3].X) * u, corners[3].Y + (corners[2].Y - corners[3].Y) * u);
+ return new PointD(top.X + (bottom.X - top.X) * v, top.Y + (bottom.Y - top.Y) * v);
+ }
+ using var image = new MagickImage("gradient:#C79A6B-#9C7148", 1600, 1000);
+ var drawing = new Drawables()
+ .FillColor(new MagickColor("#00000033")).Polygon(corners.Select(c => new PointD(c.X + 14, c.Y + 18)).ToArray())
+ .FillColor(new MagickColor("#FBFAF6")).Polygon(corners)
+ .FillColor(new MagickColor("#3E63DD")).Polygon(At(0.1, 0.09), At(0.6, 0.09), At(0.6, 0.15), At(0.1, 0.15));
+ for (var line = 0; line < 11; line++)
+ {
+ var v = 0.24 + line * 0.065;
+ var end = line % 4 == 3 ? 0.55 : 0.9;
+ drawing.FillColor(new MagickColor("#B9B6AE")).Polygon(At(0.1, v), At(end, v), At(end, v + 0.022), At(0.1, v + 0.022));
+ }
+ drawing.Draw(image);
+ image.Quality = 90;
+ image.Write(path, MagickFormat.Jpeg);
+ static Avalonia.Point Part(PointD p) => new(p.X / 1600, p.Y / 1000);
+ return new CropQuad(Part(corners[0]), Part(corners[1]), Part(corners[2]), Part(corners[3]));
+ }
+
+ ///
+ /// A made-up eight-page document (A4): each page a coloured heading with its number, a picture and lines of text.
+ /// The pages are pictures (PDFsharp draws no text without fonts set up); PdfSheets places them as it would any page.
+ ///
+ private static void DrawDocument(string path, string folder)
+ {
+ var bold = Path.Combine(RepositoryRoot, "src", "Filee.App", "Assets", "Fonts", "NotoSans-Bold.ttf");
+ string[] colours = ["#7F77DD", "#3E63DD", "#12A594", "#30A46C", "#F5A524", "#E5484D", "#D6409F", "#8E4EC6"];
+ using var document = new PdfSharp.Pdf.PdfDocument();
+ for (var n = 0; n < 8; n++)
+ {
+ var picture = Path.Combine(folder, $"page{n + 1}.jpg");
+ using (var image = new MagickImage(MagickColors.White, 1190, 1684))
+ {
+ var grey = new MagickColor("#DEDDE3");
+ var drawing = new Drawables()
+ .FillColor(new MagickColor(colours[n])).Rectangle(0, 0, 1190, 300)
+ .Font(bold).FontPointSize(210).FillColor(MagickColors.White).Text(90, 250, (n + 1).ToString(System.Globalization.CultureInfo.InvariantCulture))
+ .FillColor(new MagickColor("#FFFFFFB0")).RoundRectangle(420, 120, 1000, 150, 15, 15)
+ .FillColor(new MagickColor("#FFFFFF80")).RoundRectangle(420, 180, 860, 206, 13, 13);
+ for (var line = 0; line < 6; line++)
+ drawing.FillColor(grey).RoundRectangle(90, 400 + line * 52, line == 5 ? 700 : 1100, 424 + line * 52, 12, 12);
+ drawing.FillColor(new MagickColor(colours[(n + 3) % 8]))
+ .RoundRectangle(90, 760, 1100, 1180, 24, 24)
+ .FillColor(new MagickColor("#FFFFFF59")).Circle(800, 900, 800, 960)
+ .FillColor(new MagickColor("#FFFFFF40")).Polygon(new PointD(90, 1180), new PointD(420, 920), new PointD(700, 1180));
+ for (var line = 0; line < 6; line++)
+ drawing.FillColor(grey).RoundRectangle(90, 1260 + line * 52, line == 5 ? 560 : 1100, 1284 + line * 52, 12, 12);
+ drawing.Draw(image);
+ image.Quality = 90;
+ image.Write(picture, MagickFormat.Jpeg);
+ }
+ var page = document.AddPage();
+ page.Width = PdfSharp.Drawing.XUnit.FromPoint(595.28);
+ page.Height = PdfSharp.Drawing.XUnit.FromPoint(841.89);
+ using var gfx = PdfSharp.Drawing.XGraphics.FromPdfPage(page);
+ using var ximage = PdfSharp.Drawing.XImage.FromFile(picture);
+ gfx.DrawImage(ximage, 0, 0, 595.28, 841.89);
+ }
+ document.Save(path);
+ }
+
+ ///
+ /// Files dragged onto the donut with the gesture key held, as does: the donut opens around
+ /// the pointer, its own clock stepped with the clip's frames; Tab can turn it over, a group fans out when the pointer
+ /// heads outward on it, and a drop runs the progress ring to its check mark.
+ ///
+ private sealed class DonutDrag : IDisposable
+ {
+ private readonly Avalonia.Point _center;
+ private bool _tracking = true;
+
+ public DonutDrag(Clip clip, IReadOnlyList files, Action? prepare = null, bool clickMode = false, bool backFace = false)
+ {
+ (Window, Vm) = OpenDonut(files, clickMode, backFace);
+ prepare?.Invoke(Vm);
+ Donut = Window.DonutControl;
+ _center = new Avalonia.Point(Donut.Bounds.Width / 2, Donut.Bounds.Height / 2);
+ Scene = new Scene(Window, clip) { FrameLength = 1 / 20.0, RunsRenderTimer = false };
+ Scene.Steps.Add(dt =>
+ {
+ if (_tracking)
+ Aim();
+ KeepFramesComing(Donut);
+ Donut.Advance(dt);
+ KeepFramesComing(Donut);
+ });
+ }
+
+ /// Lights up what the pointer is on, and the hint for it in the hub.
+ public void Aim()
+ {
+ var at = Window.TranslatePoint(Scene.Pointer, Donut)!.Value;
+ Hit = Donut.HitTest(at);
+ Donut.SetExternalHit(Hit, at);
+ Vm.ShowHint(Hit.Kind is DonutHitKind.Segment or DonutHitKind.SubSegment ? Hit.Index : -1,
+ Hit.Kind == DonutHitKind.SubSegment ? Hit.Sub : -1, Hit.Kind == DonutHitKind.Center);
+ }
+
+ public RadialWindow Window { get; }
+ public RadialViewModel Vm { get; }
+ public DonutMenu Donut { get; }
+ public Scene Scene { get; }
+
+ /// What the pointer was on at the last frame.
+ public DonutHit Hit { get; private set; }
+
+ public int Index(string presetId)
+ {
+ var index = Vm.Items.ToList().FindIndex(i => i.Id == presetId);
+ Assert.True(index >= 0, $"{presetId} isn't on this face of the donut");
+ return index;
+ }
+
+ /// The middle of slice (in the window), from the hole (0) to the rim (1).
+ public Avalonia.Point Slice(int index, double depth)
+ {
+ var inner = Donut.OuterRadius * Donut.HoleRatio;
+ return InWindow(DonutGeometry.PointAt(_center, inner + (Donut.OuterRadius - inner) * depth, DonutGeometry.MidAngle(index, Donut.Items!.Count)));
+ }
+
+ public Avalonia.Point Sub(int group, int sub) => InWindow(Donut.SubSliceCenter(group, sub));
+
+ private Avalonia.Point InWindow(Avalonia.Point p) => Donut.TranslatePoint(p, Window)!.Value;
+
+ ///
+ /// The donut opens around the pointer as the drag comes in with the gesture key held, and the pointer goes on to
+ /// .
+ ///
+ public void Open(Avalonia.Point to)
+ {
+ Scene.Grab(true);
+ Scene.Place(InWindow(_center + new Avalonia.Vector(2, 3)));
+ Scene.ShowKey("Ctrl", down: true);
+ Donut.PlayOpenAnimation();
+ Scene.Tick();
+ Scene.MoveTo(to, 0.35);
+ Scene.Hold(0.35);
+ Scene.HideKey();
+ }
+
+ /// Tab turns the donut over to its other face.
+ public void Flip()
+ {
+ Scene.ShowKey("Tab", down: true);
+ Assert.True(Donut.Flip());
+ Scene.Tick();
+ Scene.ShowKey("Tab", down: false);
+ Scene.Hold(DonutMenu.FlipDuration);
+ Scene.HideKey();
+ Scene.Hold(0.3);
+ }
+
+ ///
+ /// The files dropped: the slices go into the centre, the ring fills in and the check
+ /// shows for seconds.
+ ///
+ public void Drop(double seconds, double check)
+ {
+ _tracking = false;
+ Scene.Grab(false);
+ Donut.BeginProgress();
+ var frames = (int)Math.Round(seconds / Scene.FrameLength);
+ Scene.Hold(0.25);
+ for (var i = 1; i <= frames; i++)
+ {
+ Donut.SetProgress(Scene.EaseInOut(i / (double)frames));
+ Scene.Tick();
+ }
+ Scene.Hold(0.3);
+ Donut.CompleteProgress(DonutProgressOutcome.Succeeded, () => { });
+ for (var i = 0; i < 60 && Donut.ShownOutcome is null; i++)
+ Scene.Tick();
+ Scene.Hold(check);
+ }
+
+ public void Dispose()
+ {
+ Window.Close();
+ Donut.SetExternalHighlight(-1);
+ if (Vm.IsBackFace)
+ Vm.ToggleFace();
+ }
+ }
+
+ ///
+ /// A made-up web page to mark up (1280 × 720): a travel offer with a photo, a title, a price and a button, and
+ /// three cards below. Generated, so no picture is kept in the repository.
+ ///
+ private static void DrawWebPage(string path)
+ {
+ var fonts = Path.Combine(RepositoryRoot, "src", "Filee.App", "Assets", "Fonts");
+ var (regular, bold) = (Path.Combine(fonts, "NotoSans-Regular.ttf"), Path.Combine(fonts, "NotoSans-Bold.ttf"));
+ var photoPath = Path.ChangeExtension(path, ".photo.jpg");
+ DrawLandscape(photoPath, 0);
+ using var image = new MagickImage(new MagickColor("#FFFFFF"), 1280, 720);
+ using (var photo = new MagickImage(photoPath))
+ {
+ photo.Resize(new MagickGeometry(640, 360) { IgnoreAspectRatio = true });
+ image.Composite(photo, 48, 92, CompositeOperator.Over);
+ }
+ var grey = new MagickColor("#E4E4E9");
+ var drawing = new Drawables()
+ .FillColor(new MagickColor("#F3F3F5")).Rectangle(0, 0, 1280, 56)
+ .FillColor(new MagickColor("#3E63DD")).Circle(48, 28, 48, 41)
+ .FillColor(grey).RoundRectangle(80, 20, 200, 36, 8, 8)
+ .FillColor(grey).RoundRectangle(900, 18, 980, 38, 10, 10)
+ .FillColor(grey).RoundRectangle(1000, 18, 1080, 38, 10, 10)
+ .FillColor(new MagickColor("#1C1C1C")).RoundRectangle(1100, 14, 1232, 42, 14, 14)
+ .Font(bold).FontPointSize(40).FillColor(new MagickColor("#1C1C1C")).Text(730, 150, "Three days in Lisbon")
+ .FillColor(grey).RoundRectangle(730, 186, 1200, 200, 7, 7)
+ .FillColor(grey).RoundRectangle(730, 214, 1160, 228, 7, 7)
+ .FillColor(grey).RoundRectangle(730, 242, 1010, 256, 7, 7)
+ .Font(bold).FontPointSize(34).FillColor(new MagickColor("#1C1C1C")).Text(730, 306, "€ 420")
+ .Font(regular).FontPointSize(20).FillColor(new MagickColor("#6B6B73")).Text(842, 304, "per person")
+ .FillColor(new MagickColor("#3E63DD")).RoundRectangle(730, 380, 976, 440, 12, 12)
+ .Font(bold).FontPointSize(24).FillColor(MagickColors.White).TextAlignment(TextAlignment.Center).Text(853, 419, "Book now")
+ .TextAlignment(TextAlignment.Left);
+ foreach (var x in new[] { 48, 472, 896 })
+ drawing
+ .FillColor(new MagickColor("#F6F6F8")).RoundRectangle(x, 492, x + 336, 680, 14, 14)
+ .FillColor(grey).RoundRectangle(x + 24, 528, x + 250, 544, 8, 8)
+ .FillColor(grey).RoundRectangle(x + 24, 560, x + 300, 572, 6, 6)
+ .FillColor(grey).RoundRectangle(x + 24, 584, x + 210, 596, 6, 6);
+ drawing.Draw(image);
+ image.Write(path, MagickFormat.Png);
+ }
+
// ───────────────────────── Helpers ─────────────────────────
///
@@ -655,6 +1676,9 @@ private sealed class Scene(TopLevel window, Clip clip, string? version = null)
/// Seconds per frame.
public double FrameLength { get; init; } = Frame;
+ /// The part of the window the clip shows (in the window), or null for all of it.
+ public Avalonia.Rect? Region { get; set; }
+
///
/// False for a window whose animations the clip steps itself (the donut): the render timer would also run its
/// frame loop, on wall-clock time.
@@ -684,7 +1708,7 @@ public void Tick(Func? pointer = null)
}
if (pointer is not null)
Pointer = pointer();
- var frame = Capture(window, version, pointer: Pointer, pressed: Pressed || _grabbed);
+ var frame = Capture(window, version, Region, Pointer, Pressed || _grabbed);
var shown = _key.Step(_keyCap.Shown ? 1 : 0, FrameLength);
if (shown > 0.01)
DrawKeyCap(frame, _keyCap.Label, shown, _keyCap.Down);
@@ -775,12 +1799,12 @@ private static double BackEaseOut(double t)
return 1 - p * (p * p - Math.Sin(p * Math.PI));
}
- private static (RadialWindow Window, RadialViewModel Vm) OpenDonut(IReadOnlyList files, bool clickMode)
+ private static (RadialWindow Window, RadialViewModel Vm) OpenDonut(IReadOnlyList files, bool clickMode, bool backFace = false)
{
var vm = AppHost.Get();
if (!clickMode)
vm.ResetForDrag();
- vm.Load(files, clickMode);
+ vm.Load(files, clickMode, backFace);
vm.FitToThisDonut();
// The headless platform has no transparent windows: the page colour instead of black around the donut.
var window = new RadialWindow { DataContext = vm, Background = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.Parse(Page)) };
@@ -882,18 +1906,14 @@ private static MagickImage Capture(TopLevel window, string? version = null, Aval
///
private static void KeepFramesComing(DonutMenu donut) => LastFrameTick.SetValue(donut, Environment.TickCount64);
- private static MagickImage CaptureCore(TopLevel window, string? version, Avalonia.Rect? region, Avalonia.Point? pointer, bool pressed)
+ ///
+ /// The window as it is drawn now, at its own size. draws it from its state, as the donut
+ /// always is, without a frame of the render timer.
+ ///
+ private static MagickImage Snapshot(TopLevel window, bool direct = false)
{
- if (version is not null)
- {
- foreach (var text in window.GetVisualDescendants().OfType())
- if (text.Text?.Contains(UpdateService.CurrentVersion, StringComparison.Ordinal) == true)
- text.Text = text.Text.Replace(UpdateService.CurrentVersion, version, StringComparison.Ordinal);
- Dispatcher.UIThread.RunJobs();
- AvaloniaHeadlessPlatform.ForceRenderTimerTick();
- }
using var png = new MemoryStream();
- if (window is RadialWindow)
+ if (direct || window is RadialWindow)
{
// The donut is drawn straight from its state: the clips step its clock themselves, and the render timer
// would run its own frame loop as well.
@@ -910,7 +1930,20 @@ private static MagickImage CaptureCore(TopLevel window, string? version, Avaloni
frame.Save(png, new Avalonia.Media.Imaging.PngBitmapEncoderOptions());
}
png.Position = 0;
- using var shot = new MagickImage(png);
+ return new MagickImage(png);
+ }
+
+ private static MagickImage CaptureCore(TopLevel window, string? version, Avalonia.Rect? region, Avalonia.Point? pointer, bool pressed)
+ {
+ if (version is not null)
+ {
+ foreach (var text in window.GetVisualDescendants().OfType())
+ if (text.Text?.Contains(UpdateService.CurrentVersion, StringComparison.Ordinal) == true)
+ text.Text = text.Text.Replace(UpdateService.CurrentVersion, version, StringComparison.Ordinal);
+ Dispatcher.UIThread.RunJobs();
+ AvaloniaHeadlessPlatform.ForceRenderTimerTick();
+ }
+ using var shot = Snapshot(window);
var corner = new Avalonia.Point(0, 0);
if (region is { } r)
{
@@ -1022,7 +2055,7 @@ private static void Write(string folder, string name, Func render, bool on
clip.SaveFirstFrame(Path.Combine(TestServices.ScreenshotDirectory, $"whatsnew-{language}-" + Path.ChangeExtension(name, ".png")));
Assert.InRange(new FileInfo(path).Length, 1, 400 * 1024);
if (language != "en" || clip.Poster is not null)
- SetPoster(folder, name, clip.Poster ?? 0);
+ SetPoster(folder, name, clip.PosterIn(path));
}
}
@@ -1099,6 +2132,22 @@ public void WriteWebP(string path)
public void SaveFirstFrame(string path) => _frames[0].Image.Write(path, MagickFormat.Png);
+ ///
+ /// The poster's frame in the file written to (0 without a poster): the encoder can merge
+ /// a frame into the one before when they come out the same, which moves the frames after it.
+ ///
+ public int PosterIn(string path)
+ {
+ if (Poster is not { } poster)
+ return 0;
+ using var written = new MagickImageCollection(path);
+ if (written.Count == _frames.Count)
+ return poster;
+ written.Coalesce();
+ var wanted = _frames[poster].Image;
+ return Enumerable.Range(0, written.Count).MinBy(i => wanted.Compare(written[i], ErrorMetric.RootMeanSquared));
+ }
+
public void Dispose()
{
foreach (var (image, _) in _frames)
diff --git a/tests/Filee.App.Tests/WhatsNewTests.cs b/tests/Filee.App.Tests/WhatsNewTests.cs
index 58acecb0a..95f785a25 100644
--- a/tests/Filee.App.Tests/WhatsNewTests.cs
+++ b/tests/Filee.App.Tests/WhatsNewTests.cs
@@ -33,19 +33,21 @@ private static SlideDeck Deck(string version) =>
// ───────────────────────── Manifests ─────────────────────────
- [AvaloniaFact]
- public void The_2_0_0_deck_ships_with_its_media_and_texts()
+ [AvaloniaTheory]
+ [InlineData("2.0.0")]
+ [InlineData("2.3.0")]
+ public void A_deck_ships_with_its_media_and_texts(string version)
{
TestServices.EnsureInitialized("en");
var decks = SlideDeck.LoadAll("Filee");
- var deck = Assert.Single(decks, d => d.Version == V2);
+ var deck = Assert.Single(decks, d => d.Version == new Version(version));
Assert.InRange(deck.Slides.Count, 5, 7);
Assert.Equal(deck.Slides.Count, deck.Slides.Select(s => s.Id).Distinct().Count());
var english = LoadStrings("en");
foreach (var slide in deck.Slides)
{
Assert.True(AssetLoader.Exists(slide.Media), $"{slide.Media} is missing");
- Assert.StartsWith("avares://Filee/Assets/WhatsNew/2.0.0/", slide.Media.ToString(), StringComparison.Ordinal);
+ Assert.StartsWith($"avares://Filee/Assets/WhatsNew/{version}/", slide.Media.ToString(), StringComparison.Ordinal);
Assert.Contains(slide.TitleKey, english.Keys);
Assert.Contains(slide.TextKey, english.Keys);
}
@@ -318,11 +320,11 @@ public void The_version_updated_from_decides_over_the_remembered_one()
// ───────────────────────── The window ─────────────────────────
- private static WhatsNewWindow Open(string language = "en", Action? beforeOpening = null)
+ private static WhatsNewWindow Open(string language = "en", Action? beforeOpening = null, Version? version = null)
{
TestServices.EnsureInitialized(language);
beforeOpening?.Invoke();
- var deck = SlideDeck.LoadAll("Filee").Single(d => d.Version == V2);
+ var deck = SlideDeck.LoadAll("Filee").Single(d => d.Version == (version ?? V2));
var window = new WhatsNewWindow(deck, AppHost.Get());
window.Show();
window.Activate();
@@ -604,6 +606,55 @@ public void Window_renders(string language, bool dark)
}
}
+ ///
+ /// The 2.3.0 slides in a few languages: every slide's text fits and, with reduced motion, shows its poster frame
+ /// (what the installer shows too). A screenshot of each slide.
+ ///
+ [AvaloniaTheory]
+ [InlineData("en", false)]
+ [InlineData("en", true)]
+ [InlineData("ko", false)]
+ [InlineData("ar", false)]
+ [InlineData("de", false)]
+ [InlineData("ja", false)]
+ [InlineData("it", false)]
+ [InlineData("id", false)]
+ public void The_2_3_0_slides_render_on_their_posters(string language, bool dark)
+ {
+ TestServices.EnsureInitialized(language);
+ var theme = AppHost.Get();
+ var motion = Motion.Enabled;
+ var window = Open(language, () =>
+ {
+ theme.Apply(new ThemeSettings { Mode = dark ? ThemeMode.Dark : ThemeMode.Light }, language);
+ Motion.Set(false);
+ }, new Version(2, 3, 0));
+ try
+ {
+ var slides = window.SlideShow;
+ Assert.Equal(6, slides.Slides.Count);
+ var heights = new HashSet();
+ for (var i = 0; i < slides.Slides.Count; i++)
+ {
+ slides.GoTo(i);
+ Pump();
+ heights.Add(window.Bounds.Height);
+ var media = slides.Media;
+ Assert.True(media.HasPicture, $"slide {i} has no picture");
+ Assert.InRange(slides.Slides[i].PosterFrame, 0, media.FrameCount - 1);
+ Assert.Equal(slides.Slides[i].PosterFrame, media.CurrentFrame);
+ Save(window, $"whatsnew-2.3.0-{language}-{(dark ? "dark" : "light")}-{i + 1}-{slides.Slides[i].Id}.png");
+ }
+ Assert.Single(heights);
+ }
+ finally
+ {
+ window.Close();
+ Motion.Set(motion);
+ theme.Apply(new ThemeSettings(), "en");
+ }
+ }
+
private static Dictionary LoadStrings(string code) =>
JsonSerializer.Deserialize>(File.ReadAllText(
Path.Combine(WhatsNewMedia.RepositoryRoot, "src", "Filee.App", "Assets", "i18n", code + ".json")))!;