diff --git a/docs/ENGINES.md b/docs/ENGINES.md
index 1428cabf3..b95a12d14 100644
--- a/docs/ENGINES.md
+++ b/docs/ENGINES.md
@@ -5,7 +5,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status,
| Engine | How it gets there | Handles |
|---|---|---|
-| ImageMagick (Magick.NET) | built in (library) | JPG, PNG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, MNG, QOI, DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster, MIFF ↔ each other, and **APNG** (Filee's own reader and writer); HEIC (written with FFmpeg), XCF, camera RAW (LibRaw), DICOM (uncompressed ones by Filee's own reader), **FITS** (Filee's own reader; TIFF at the file's own depth by Filee's own writer), Photo CD, OpenEXR, Radiance HDR, EMF/WMF (Windows), the photo of an iPhone LIVP and the merged picture of a Krita KRA read |
+| ImageMagick (Magick.NET) | built in (library) | JPG, PNG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, MNG, QOI, DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster, MIFF ↔ each other, and **APNG** (Filee's own reader and writer); HEIC (written with FFmpeg), XCF, camera RAW (LibRaw), DICOM (uncompressed ones by Filee's own reader), **FITS** (Filee's own reader; TIFF at the file's own depth by Filee's own writer), Photo CD, OpenEXR, Radiance HDR, EMF/WMF (Windows), the photo of an iPhone LIVP, the merged picture of a Krita KRA and the icon of a Windows program or library (EXE, DLL) read |
| Windows Imaging Component | part of Windows (Windows only) | **JPEG XR** (JXR, WDP, HDP; HDR screenshots of Windows) → the images ImageMagick writes, and PDF, ICNS, HEIC and SVG through PNG; HDR tone-mapped to SDR |
| Vector graphics | built in (Svg.Skia, SkiaSharp, ACadSharp) | **SVG/SVGZ → PDF (vector) and images**, SVG ↔ SVGZ, **SVG → DXF** (→ DWG with the CAD engine), **raster images → SVG (tracing)**, PDF-compatible AI → PDF, **HP-GL / HP-GL/2 plot files (PLT) → SVG**, PDF and images (pen moves, packed PE polylines, arcs and filled polygons; text labels are not drawn), **Tajima DST embroidery → SVG** (stitches as lines, a palette colour per thread; → PNG, JPG, PDF from there) |
| ICNS | built in | images → ICNS (16–1024 px), ICNS → images |
@@ -329,6 +329,13 @@ in it also builds the package (artifact `windows-arm64-package`).
without one gives a clear error. Krita's backup of the previous save, `name.kra~`, is the same file and is read the
same way; its output is named `name.png`. Watch folders skip it (and any name ending in `~`), as it is written next
to the drawing on every save.
+- **EXE / DLL** (a Windows program or library, #1188) is read as its icon, the one Explorer shows: the first icon group
+ of its resources, put back together as an `.ico` in memory (`ProgramIcon`, Filee's own reader for 32 and 64-bit PE
+ files; only the headers and the icon are read, at most 64 MB, and the file is never loaded or run). To ICO it is
+ that icon as it is, every size the program has, unless the preset changes the picture or asks for other icon
+ sizes; then, as for every other target, the largest size is converted. A file without an icon of its own, or a
+ 16-bit or damaged one, gives a clear error. Programs are converted only when dropped or named: watch folders and
+ folders given to the command line skip them, and Recent conversions never opens one.
- **Live / Motion Photos from a video** (JPG and HEIC targets) are FFmpeg's: the clip and the photo's frame come from
it, ImageMagick or the HEIC writer only encode that frame (see *Making Live / Motion Photos* there).
- **Camera RAW** (DNG, CR2, CR3, NEF, ARW, …, and `.raw`) is decoded by LibRaw with the camera's white balance,
diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json
index b9ed328f4..891d8fa13 100644
--- a/src/Filee.App/Assets/i18n/ar.json
+++ b/src/Filee.App/Assets/i18n/ar.json
@@ -164,6 +164,8 @@
"error.fon_too_large": "خط .fon كبير جدًا بحيث لا يمكن تحويله",
"error.fon_damaged": "ملف .fon تالف أو غير مكتمل",
"error.m4s_needs_init": "ملف .m4s هذا جزء من فيديو متدفّق، ولا يمكن قراءته دون مقطع البداية (init) المنفصل الخاص به",
+ "error.exe_unreadable": "هذا ليس برنامج Windows أو مكتبة يستطيع Filee قراءتها، أو أن الملف تالف",
+ "error.exe_no_icon": "لا يحتوي هذا الملف على أيقونة خاصة به (يعرض Windows له أيقونة قياسية)",
"notice.pdf_not_smaller": "تعذّر تقليل الحجم، لذا نُسخ الملف كما هو",
"notice.under_min_size": "تم الحفظ، لكنه أصغر من الحد الأدنى لحجم الملف",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/bn-BD.json b/src/Filee.App/Assets/i18n/bn-BD.json
index a2ae8a78e..d538a9474 100644
--- a/src/Filee.App/Assets/i18n/bn-BD.json
+++ b/src/Filee.App/Assets/i18n/bn-BD.json
@@ -163,6 +163,8 @@
"error.fon_too_large": "রূপান্তরের জন্য .fon ফন্টটি অতিরিক্ত বড়",
"error.fon_damaged": ".fon ফাইলটি ক্ষতিগ্রস্ত বা অসম্পূর্ণ",
"error.m4s_needs_init": "এই .m4s ফাইলটি স্ট্রিম করা ভিডিওর একটি অংশ: এর আলাদা সূচনা (init) অংশ ছাড়া এটি পড়া যাবে না",
+ "error.exe_unreadable": "এটি এমন কোনো Windows প্রোগ্রাম বা লাইব্রেরি নয় যা Filee পড়তে পারে, অথবা ফাইলটি ক্ষতিগ্রস্ত",
+ "error.exe_no_icon": "এই ফাইলের নিজস্ব কোনো আইকন নেই (Windows এর জন্য একটি সাধারণ আইকন দেখায়)",
"notice.pdf_not_smaller": "আকার আরও ছোট করা যায়নি, তাই এটি অপরিবর্তিত অবস্থায় কপি করা হয়েছে",
"notice.under_min_size": "সংরক্ষণ করা হয়েছে, তবে ফাইলের সর্বনিম্ন আকারের চেয়ে ছোট",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json
index 44e84c8df..4ec3f17fc 100644
--- a/src/Filee.App/Assets/i18n/de.json
+++ b/src/Filee.App/Assets/i18n/de.json
@@ -164,6 +164,8 @@
"error.fon_too_large": "Die .fon-Schrift ist zu groß zum Konvertieren",
"error.fon_damaged": "Die .fon-Datei ist beschädigt oder unvollständig",
"error.m4s_needs_init": "Diese .m4s-Datei ist ein Stück eines gestreamten Videos und lässt sich ohne ihr separates Startsegment (init) nicht lesen",
+ "error.exe_unreadable": "Das ist kein Windows-Programm und keine Bibliothek, die Filee lesen kann, oder die Datei ist beschädigt",
+ "error.exe_no_icon": "Diese Datei hat kein eigenes Symbol (Windows zeigt für sie ein Standardsymbol)",
"notice.pdf_not_smaller": "Die Datei ließ sich nicht verkleinern und wurde unverändert kopiert",
"notice.under_min_size": "Gespeichert, aber kleiner als die Mindestdateigröße",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index cde8ed792..d368a4c2e 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -170,6 +170,8 @@
"error.fon_too_large": "The .fon font is too large to convert",
"error.fon_damaged": "The .fon file is damaged or cut short",
"error.m4s_needs_init": "This .m4s is one piece of a streamed video: it can't be read without its separate start (init) segment",
+ "error.exe_unreadable": "This isn't a Windows program or library Filee can read, or it is damaged",
+ "error.exe_no_icon": "This file has no icon of its own (Windows shows a standard one for it)",
"notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is",
"notice.under_min_size": "Saved, but smaller than the minimum file size",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json
index 291a8fd8a..f0beb9adf 100644
--- a/src/Filee.App/Assets/i18n/es.json
+++ b/src/Filee.App/Assets/i18n/es.json
@@ -164,6 +164,8 @@
"error.fon_too_large": "La fuente .fon es demasiado grande para convertirla",
"error.fon_damaged": "El archivo .fon está dañado o incompleto",
"error.m4s_needs_init": "Este .m4s es un fragmento de un vídeo en streaming y no se puede leer sin su segmento de inicio (init) aparte",
+ "error.exe_unreadable": "No es un programa ni una biblioteca de Windows que Filee pueda leer, o el archivo está dañado",
+ "error.exe_no_icon": "Este archivo no tiene icono propio (Windows le muestra uno genérico)",
"notice.pdf_not_smaller": "No se pudo reducir su tamaño, por lo que se copió tal cual",
"notice.under_min_size": "Guardado, pero más pequeño que el tamaño mínimo de archivo",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/fa.json b/src/Filee.App/Assets/i18n/fa.json
index 16f747a5a..5f1257075 100644
--- a/src/Filee.App/Assets/i18n/fa.json
+++ b/src/Filee.App/Assets/i18n/fa.json
@@ -163,6 +163,8 @@
"error.fon_too_large": "فونت .fon برای تبدیل بیش از حد بزرگ است",
"error.fon_damaged": "فایل .fon آسیب دیده یا ناقص است",
"error.m4s_needs_init": "این فایل .m4s بخشی از یک ویدیوی استریم است: بدون بخش شروع مجزای آن (init) قابل خواندن نیست",
+ "error.exe_unreadable": "این برنامه یا کتابخانهٔ Windows نیست که Filee بتواند آن را بخواند، یا فایل آسیب دیده است",
+ "error.exe_no_icon": "این فایل آیکون مخصوص به خود ندارد (Windows برای آن یک آیکون استاندارد نشان میدهد)",
"notice.pdf_not_smaller": "امکان کوچکتر کردن فایل نبود، بنابراین همانطور که بود کپی شد",
"notice.under_min_size": "ذخیره شد، اما از حداقل اندازه فایل کوچکتر است",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json
index 91e353d59..978399959 100644
--- a/src/Filee.App/Assets/i18n/fr.json
+++ b/src/Filee.App/Assets/i18n/fr.json
@@ -170,6 +170,8 @@
"error.fon_too_large": "La police .fon est trop grande pour être convertie",
"error.fon_damaged": "Le fichier .fon est endommagé ou incomplet",
"error.m4s_needs_init": "Ce .m4s est un morceau d'une vidéo diffusée en streaming : il ne peut pas être lu sans son segment de départ (init) séparé",
+ "error.exe_unreadable": "Ce n'est pas un programme ni une bibliothèque Windows que Filee peut lire, ou le fichier est endommagé",
+ "error.exe_no_icon": "Ce fichier n'a pas d'icône propre (Windows lui affiche une icône standard)",
"notice.pdf_not_smaller": "Impossible de réduire sa taille : le fichier a été copié tel quel",
"notice.under_min_size": "Enregistré, mais plus petit que la taille de fichier minimale",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json
index 7870c4e03..dbcd7e0c9 100644
--- a/src/Filee.App/Assets/i18n/hi.json
+++ b/src/Filee.App/Assets/i18n/hi.json
@@ -164,6 +164,8 @@
"error.fon_too_large": ".fon फ़ॉन्ट बदलने के लिए बहुत बड़ा है",
"error.fon_damaged": ".fon फ़ाइल खराब है या अधूरी है",
"error.m4s_needs_init": "यह .m4s स्ट्रीम किए गए वीडियो का एक हिस्सा है: इसके अलग शुरुआती (init) सेगमेंट के बिना इसे पढ़ा नहीं जा सकता",
+ "error.exe_unreadable": "यह कोई ऐसा Windows प्रोग्राम या लाइब्रेरी नहीं है जिसे Filee पढ़ सके, या फ़ाइल खराब है",
+ "error.exe_no_icon": "इस फ़ाइल का अपना कोई आइकन नहीं है (Windows इसके लिए एक मानक आइकन दिखाता है)",
"notice.pdf_not_smaller": "इसे छोटा नहीं किया जा सका, इसलिए जैसा है वैसा ही कॉपी कर दिया गया",
"notice.under_min_size": "सहेजा गया, लेकिन न्यूनतम फ़ाइल आकार से छोटा है",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/id.json b/src/Filee.App/Assets/i18n/id.json
index 977120b9b..63fb39b20 100644
--- a/src/Filee.App/Assets/i18n/id.json
+++ b/src/Filee.App/Assets/i18n/id.json
@@ -164,6 +164,8 @@
"error.fon_too_large": "Font .fon terlalu besar untuk dikonversi",
"error.fon_damaged": "File .fon rusak atau terpotong",
"error.m4s_needs_init": "File .m4s ini adalah salah satu pecahan video streaming: tidak dapat dibaca tanpa segmen awal (init) terpisahnya",
+ "error.exe_unreadable": "Ini bukan program atau pustaka Windows yang dapat dibaca Filee, atau file rusak",
+ "error.exe_no_icon": "File ini tidak punya ikon sendiri (Windows menampilkan ikon standar untuknya)",
"notice.pdf_not_smaller": "Ukuran tidak dapat diperkecil lagi, sehingga disalin apa adanya",
"notice.under_min_size": "Disimpan, tetapi lebih kecil dari ukuran file minimum",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/it.json b/src/Filee.App/Assets/i18n/it.json
index 4159a2fb5..f59e0dba0 100644
--- a/src/Filee.App/Assets/i18n/it.json
+++ b/src/Filee.App/Assets/i18n/it.json
@@ -163,6 +163,8 @@
"error.fon_too_large": "Il font .fon è troppo grande per essere convertito",
"error.fon_damaged": "Il file .fon è danneggiato o troncato",
"error.m4s_needs_init": "Questo .m4s è un frammento di un video in streaming: non può essere letto senza il suo segmento iniziale (init) separato",
+"error.exe_unreadable": "Non è un programma o una libreria di Windows che Filee può leggere, oppure il file è danneggiato",
+"error.exe_no_icon": "Questo file non ha un'icona propria (Windows gli mostra un'icona standard)",
"notice.pdf_not_smaller": "Impossibile ridurlo, quindi è stato copiato così com'è",
"notice.under_min_size": "Salvato, ma più piccolo della dimensione minima del file",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json
index 5017ad438..38f66fac1 100644
--- a/src/Filee.App/Assets/i18n/ja.json
+++ b/src/Filee.App/Assets/i18n/ja.json
@@ -164,6 +164,8 @@
"error.fon_too_large": ".fonフォントが大きすぎて変換できません",
"error.fon_damaged": ".fonファイルが破損しているか、途中で切れています",
"error.m4s_needs_init": "この .m4s はストリーミング動画の一部で、別にある開始(init)セグメントがないと読み込めません",
+ "error.exe_unreadable": "Filee で読み込める Windows のプログラムやライブラリではないか、ファイルが壊れています",
+ "error.exe_no_icon": "このファイルには独自のアイコンがありません(Windows では標準のアイコンが表示されます)",
"notice.pdf_not_smaller": "サイズを小さくできなかったため、元のままコピーしました",
"notice.under_min_size": "保存しましたが、最小ファイルサイズより小さいです",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index 02cd6ce38..03a36a10b 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -170,6 +170,8 @@
"error.fon_too_large": ".fon 글꼴이 너무 커서 변환할 수 없어요",
"error.fon_damaged": ".fon 파일이 손상되었거나 잘렸어요",
"error.m4s_needs_init": "스트리밍 동영상의 한 조각인 .m4s 파일이에요. 따로 있는 시작(init) 세그먼트 없이는 읽을 수 없어요",
+ "error.exe_unreadable": "Filee가 읽을 수 있는 Windows 프로그램이나 라이브러리가 아니거나 파일이 손상되었어요",
+ "error.exe_no_icon": "이 파일에는 자체 아이콘이 없어요 (Windows가 기본 아이콘을 보여 줘요)",
"notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요",
"notice.under_min_size": "최소 파일 크기보다 작지만 저장했어요",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json
index 20b656f25..8db425682 100644
--- a/src/Filee.App/Assets/i18n/ne.json
+++ b/src/Filee.App/Assets/i18n/ne.json
@@ -164,6 +164,8 @@
"error.fon_too_large": ".fon फन्ट रूपान्तरण गर्न धेरै ठूलो छ",
"error.fon_damaged": ".fon फाइल बिग्रिएको वा अपूर्ण छ",
"error.m4s_needs_init": "यो .m4s स्ट्रिम गरिएको भिडियोको एउटा टुक्रा हो: छुट्टै सुरुवाती (init) खण्डबिना यसलाई पढ्न सकिँदैन",
+ "error.exe_unreadable": "यो Filee ले पढ्न सक्ने Windows प्रोग्राम वा लाइब्रेरी होइन, वा फाइल बिग्रिएको छ",
+ "error.exe_no_icon": "यो फाइलको आफ्नै आइकन छैन (Windows ले यसका लागि मानक आइकन देखाउँछ)",
"notice.pdf_not_smaller": "यसलाई सानो बनाउन सकिएन, त्यसैले जस्तो छ त्यस्तै प्रतिलिपि गरियो",
"notice.under_min_size": "सुरक्षित भयो, तर न्यूनतम फाइल आकारभन्दा सानो छ",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json
index 925a31d98..a3c39b7e5 100644
--- a/src/Filee.App/Assets/i18n/pl.json
+++ b/src/Filee.App/Assets/i18n/pl.json
@@ -170,6 +170,8 @@
"error.fon_too_large": "Czcionka .fon jest zbyt duża, aby ją przekonwertować",
"error.fon_damaged": "Plik .fon jest uszkodzony lub niekompletny",
"error.m4s_needs_init": "Ten plik .m4s to fragment wideo strumieniowego i nie da się go odczytać bez osobnego segmentu początkowego (init)",
+ "error.exe_unreadable": "To nie jest program ani biblioteka Windows, którą Filee potrafi odczytać, albo plik jest uszkodzony",
+ "error.exe_no_icon": "Ten plik nie ma własnej ikony (Windows pokazuje dla niego ikonę standardową)",
"notice.pdf_not_smaller": "Nie udało się zmniejszyć pliku, więc skopiowano go bez zmian",
"notice.under_min_size": "Zapisano, ale plik jest mniejszy niż minimalny rozmiar",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json
index 1c58b33f4..5d4fbca8d 100644
--- a/src/Filee.App/Assets/i18n/pt-BR.json
+++ b/src/Filee.App/Assets/i18n/pt-BR.json
@@ -170,6 +170,8 @@
"error.fon_too_large": "A fonte .fon é grande demais para converter",
"error.fon_damaged": "O arquivo .fon está danificado ou incompleto",
"error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo transmitido por streaming e não pode ser lido sem o seu segmento inicial (init) separado",
+ "error.exe_unreadable": "Não é um programa ou biblioteca do Windows que o Filee consiga ler, ou o arquivo está danificado",
+ "error.exe_no_icon": "Este arquivo não tem ícone próprio (o Windows mostra um ícone padrão para ele)",
"notice.pdf_not_smaller": "Não deu para deixar menor, então foi copiado como está",
"notice.under_min_size": "Salvo, mas menor que o tamanho mínimo do arquivo",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json
index 260267c33..d9639d098 100644
--- a/src/Filee.App/Assets/i18n/pt-PT.json
+++ b/src/Filee.App/Assets/i18n/pt-PT.json
@@ -170,6 +170,8 @@
"error.fon_too_large": "A fonte .fon é demasiado grande para converter",
"error.fon_damaged": "O ficheiro .fon está danificado ou incompleto",
"error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo em streaming e não pode ser lido sem o respetivo segmento inicial (init) separado",
+ "error.exe_unreadable": "Não é um programa ou biblioteca do Windows que o Filee consiga ler, ou o ficheiro está danificado",
+ "error.exe_no_icon": "Este ficheiro não tem ícone próprio (o Windows mostra-lhe um ícone predefinido)",
"notice.pdf_not_smaller": "Não foi possível reduzir o tamanho, pelo que o ficheiro foi copiado sem alterações",
"notice.under_min_size": "Guardado, mas mais pequeno do que o tamanho mínimo do ficheiro",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json
index 0225c1d5e..612f037e4 100644
--- a/src/Filee.App/Assets/i18n/ru.json
+++ b/src/Filee.App/Assets/i18n/ru.json
@@ -170,6 +170,8 @@
"error.fon_too_large": "Шрифт .fon слишком велик для преобразования",
"error.fon_damaged": "Файл .fon повреждён или обрезан",
"error.m4s_needs_init": "Этот .m4s — фрагмент потокового видео: без отдельного начального сегмента (init) его не прочитать",
+ "error.exe_unreadable": "Это не программа или библиотека Windows, которую Filee может прочитать, или файл повреждён",
+ "error.exe_no_icon": "У этого файла нет собственного значка (Windows показывает для него стандартный)",
"notice.pdf_not_smaller": "Уменьшить не получилось, файл скопирован как есть",
"notice.under_min_size": "Сохранено, но меньше минимального размера файла",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json
index c205a56ae..bbd3c04d4 100644
--- a/src/Filee.App/Assets/i18n/th.json
+++ b/src/Filee.App/Assets/i18n/th.json
@@ -164,6 +164,8 @@
"error.fon_too_large": "ฟอนต์ .fon มีขนาดใหญ่เกินกว่าจะแปลงได้",
"error.fon_damaged": "ไฟล์ .fon เสียหายหรือไม่สมบูรณ์",
"error.m4s_needs_init": "ไฟล์ .m4s นี้เป็นชิ้นส่วนหนึ่งของวิดีโอสตรีมมิง และอ่านไม่ได้หากไม่มีเซกเมนต์เริ่มต้น (init) ที่แยกไว้",
+ "error.exe_unreadable": "นี่ไม่ใช่โปรแกรมหรือไลบรารีของ Windows ที่ Filee อ่านได้ หรือไฟล์เสียหาย",
+ "error.exe_no_icon": "ไฟล์นี้ไม่มีไอคอนของตัวเอง (Windows จะแสดงไอคอนมาตรฐานให้)",
"notice.pdf_not_smaller": "ไม่สามารถบีบอัดให้เล็กลงได้ จึงคัดลอกไฟล์ตามเดิม",
"notice.under_min_size": "บันทึกแล้ว แต่เล็กกว่าขนาดไฟล์ขั้นต่ำ",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json
index 238c09066..d71a72f93 100644
--- a/src/Filee.App/Assets/i18n/tr.json
+++ b/src/Filee.App/Assets/i18n/tr.json
@@ -164,6 +164,8 @@
"error.fon_too_large": ".fon yazı tipi dönüştürmek için çok büyük",
"error.fon_damaged": ".fon dosyası hasarlı veya eksik",
"error.m4s_needs_init": "Bu .m4s, akışla gelen bir videonun bir parçası: ayrı başlangıç (init) segmenti olmadan okunamaz",
+ "error.exe_unreadable": "Bu, Filee'nin okuyabildiği bir Windows programı veya kitaplığı değil ya da dosya hasarlı",
+ "error.exe_no_icon": "Bu dosyanın kendi simgesi yok (Windows onun için standart bir simge gösterir)",
"notice.pdf_not_smaller": "Küçültülemediği için olduğu gibi kopyalandı",
"notice.under_min_size": "Kaydedildi, ancak en küçük dosya boyutundan küçük",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/vi.json b/src/Filee.App/Assets/i18n/vi.json
index 90c60b930..1c41924d8 100644
--- a/src/Filee.App/Assets/i18n/vi.json
+++ b/src/Filee.App/Assets/i18n/vi.json
@@ -170,6 +170,8 @@
"error.fon_too_large": "Phông chữ .fon quá lớn để chuyển đổi",
"error.fon_damaged": "Tệp .fon bị hỏng hoặc bị cắt cụt",
"error.m4s_needs_init": "Tệp .m4s này là một phần của video phát trực tuyến: không thể đọc nếu thiếu đoạn đầu (init) riêng của nó",
+ "error.exe_unreadable": "Đây không phải chương trình hay thư viện Windows mà Filee đọc được, hoặc tệp bị hỏng",
+ "error.exe_no_icon": "Tệp này không có biểu tượng riêng (Windows hiển thị biểu tượng mặc định cho nó)",
"notice.pdf_not_smaller": "Không thể làm nhỏ hơn nên đã sao chép nguyên trạng",
"notice.under_min_size": "Đã lưu, nhưng nhỏ hơn kích thước tệp tối thiểu",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index ea08c5601..d10518c7f 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -170,6 +170,8 @@
"error.fon_too_large": ".fon 字体太大,无法转换",
"error.fon_damaged": ".fon 文件已损坏或不完整",
"error.m4s_needs_init": "此 .m4s 是流媒体视频的一个片段,没有单独的起始(init)分段就无法读取",
+ "error.exe_unreadable": "这不是 Filee 能读取的 Windows 程序或库,或文件已损坏",
+ "error.exe_no_icon": "此文件没有自己的图标(Windows 会为它显示标准图标)",
"notice.pdf_not_smaller": "无法再缩小,已原样复制",
"notice.under_min_size": "已保存,但小于最小文件大小",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json
index d3ff7cd7c..443b53991 100644
--- a/src/Filee.App/Assets/i18n/zh-TW.json
+++ b/src/Filee.App/Assets/i18n/zh-TW.json
@@ -170,6 +170,8 @@
"error.fon_too_large": ".fon 字型太大,無法轉換",
"error.fon_damaged": ".fon 檔案已損壞或不完整",
"error.m4s_needs_init": "此 .m4s 是串流影片的一個片段,沒有另外的起始(init)區段就無法讀取",
+ "error.exe_unreadable": "這不是 Filee 能讀取的 Windows 程式或程式庫,或檔案已損壞",
+ "error.exe_no_icon": "此檔案沒有自己的圖示(Windows 會為它顯示標準圖示)",
"notice.pdf_not_smaller": "無法再縮小,已原樣複製",
"notice.under_min_size": "已儲存,但小於最小檔案大小",
"notice.with_under_min_size": "{0} · {1}",
diff --git a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
index d1655373b..304e3b214 100644
--- a/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
+++ b/src/Filee.App/ViewModels/Pages/HomePageViewModel.cs
@@ -46,9 +46,10 @@ public HistoryItemViewModel(HistoryEntry entry, ILocalizer loc, IPlatformService
FailureSummary = loc.Format("home.failed_files", Failures.Count);
_output = entry.Outputs.FirstOrDefault(ConversionService.Exists);
HasOutputs = _output is not null;
- // Only a file of a format Filee knows is opened (never a program a changed history could point to); an
- // output folder ("Extract") opens in the file manager.
- CanOpen = _output is not null && (FormatRegistry.Detect(_output) is not null || Directory.Exists(_output));
+ // Only a file of a format Filee knows is opened (never a program a changed history could point to, though Filee
+ // reads the icon of one); an output folder ("Extract") opens in the file manager.
+ CanOpen = _output is not null && (FormatRegistry.Detect(_output) is not null && !FormatRegistry.IsProgram(_output)
+ || Directory.Exists(_output));
CanOpenWith = CanOpen && platform.SupportsOpenWith && !Directory.Exists(_output!);
// The card shows the result: its picture when it is one, else its format. A job without results shows a source.
diff --git a/src/Filee.Cli/Cli.cs b/src/Filee.Cli/Cli.cs
index 6120ee959..f675ca0e3 100644
--- a/src/Filee.Cli/Cli.cs
+++ b/src/Filee.Cli/Cli.cs
@@ -346,7 +346,8 @@ private static void UseFolder(Preset preset, string folder)
///
/// Files from the arguments: files as given, wildcards ("*.heic", which cmd.exe and PowerShell pass on unexpanded)
- /// and folders (their files in formats Filee knows; with --recursive also in sub folders).
+ /// and folders (their files in formats Filee knows, programs left out as in watch folders: the installers in a folder
+ /// aren't pictures to convert; with --recursive also in sub folders).
///
internal static List ExpandInputs(IEnumerable inputs, bool recursive, List missing, bool foldersAsInputs = false)
{
@@ -367,7 +368,8 @@ internal static List ExpandInputs(IEnumerable inputs, bool recur
else if (Directory.Exists(input))
{
if (foldersAsInputs) files.Add(Path.TrimEndingDirectorySeparator(input));
- else files.AddRange(Directory.EnumerateFiles(input, "*", option).Where(f => FormatRegistry.Detect(f) is not null));
+ else files.AddRange(Directory.EnumerateFiles(input, "*", option)
+ .Where(f => FormatRegistry.Detect(f) is not null && !FormatRegistry.IsProgram(f)));
}
else if (File.Exists(input))
files.Add(input);
diff --git a/src/Filee.Core/Formats/FormatFields.cs b/src/Filee.Core/Formats/FormatFields.cs
index 07f72bbb3..dae0079d1 100644
--- a/src/Filee.Core/Formats/FormatFields.cs
+++ b/src/Filee.Core/Formats/FormatFields.cs
@@ -64,7 +64,7 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow
]),
(FormatGrade.General,
[
- "tiff", "bmp", "ico", "avif", "jxl", "tga", "apng", "jxr", "livp", "svg", "svgz", "emf", "vsd", "odg",
+ "tiff", "bmp", "ico", "avif", "jxl", "tga", "apng", "jxr", "livp", "exe", "svg", "svgz", "emf", "vsd", "odg",
"docm", "dotx", "dotm", "doc", "odt", "ott", "rtf", "wps", "ofd", "abw", "pages", "pub", "eml", "msg",
"p7m", "xlsm", "xltx", "xls", "ods", "ots", "tsv", "et", "numbers", "pptm", "potx", "potm", "ppsx", "ppt",
"odp", "otp", "key", "dps", "md", "html", "mhtml", "opml", "json", "xml", "vcf", "ics", "epub", "kepub",
diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs
index 60287b6d9..9cfb125fc 100644
--- a/src/Filee.Core/Formats/FormatRegistry.cs
+++ b/src/Filee.Core/Formats/FormatRegistry.cs
@@ -10,6 +10,11 @@ public static class FormatRegistry
/// Pseudo format for archive extraction outputs and folders explicitly classified at input handling.
public const string Folder = "folder";
+ ///
+ /// Windows programs and libraries (.exe, .dll), read as the icon Explorer shows for them (#1188). They are never run.
+ ///
+ public const string Program = "exe";
+
/// All known formats. Order is the order shown in format pickers.
public static IReadOnlyList Known { get; } =
[
@@ -60,6 +65,8 @@ public static class FormatRegistry
new("kra", "Krita (KRA)", FormatCategory.Image, ["kra", "kra~"], Writable: false),
// iPhone Live Photo as one ZIP (iCloud downloads): the photo, or its video with FFmpeg.
new("livp", "Live Photo (LIVP)", FormatCategory.Image, ["livp"], Writable: false),
+ // A Windows program or library (#1188), read as its icon: the picture Explorer shows for the file.
+ new(Program, "Windows program icon (EXE, DLL)", FormatCategory.Image, ["exe", "dll"], Writable: false),
new("raw", "RAW", FormatCategory.Image,
["dng", "cr2", "cr3", "crw", "nef", "nrw", "arw", "srf", "sr2", "orf", "rw2", "raf", "pef", "dcr", "kdc",
"erf", "mos", "mrw", "x3f", "3fr", "fff", "iiq", "mef", "rwl", "srw", "raw"], Writable: false),
@@ -399,6 +406,12 @@ public static FileFormat Get(string id) =>
/// Detects the format of a file from its extension, or null if it is not supported.
public static FileFormat? Detect(string path) => FindByExtension(ExtensionOf(path));
+ ///
+ /// True for a Windows program or library (). Filee reads its icon when the file is dropped or
+ /// named, but never opens one (it would start the program) and never takes one from a folder by itself.
+ ///
+ public static bool IsProgram(string path) => Detect(path)?.Id == Program;
+
///
/// The extension of without the dot, including known two-part extensions
/// ("archive.tar.gz" → "tar.gz", "photo.jpg" → "jpg").
diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs
index d28dcdf10..a7de18ee7 100644
--- a/src/Filee.Core/Settings/AppSettings.cs
+++ b/src/Filee.Core/Settings/AppSettings.cs
@@ -132,7 +132,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest
public sealed class AppSettings
{
/// Current schema version. Increase when the format changes and add a migration.
- public const int CurrentSchemaVersion = 77;
+ public const int CurrentSchemaVersion = 78;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index 3a28ec415..d8d7825a8 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -1085,6 +1085,10 @@ public static bool ApplyToLibrary(int from, List presets, List ProcessPendingAsync()
}
/// True for files a watch folder never converts: temporary, hidden or system files, backups of the previous
- /// save ("sketch.kra~"), unknown formats, anything inside the output or originals folder, and names the rule's
- /// leaves out.
+ /// save ("sketch.kra~"), programs, unknown formats, anything inside the output or originals folder, and names the
+ /// rule's leaves out.
public static bool ShouldIgnore(string path, WatchRule rule)
{
if (Presets.FolderCopy.IsWorkPath(path))
@@ -265,7 +265,9 @@ public static bool ShouldIgnore(string path, WatchRule rule)
return true;
if (!rule.IncludeSubfolders && !SameFolder(Path.GetDirectoryName(path), rule.Folder))
return true;
- if (FormatRegistry.Detect(path) is null)
+ // A program is read as its icon only when one is dropped or named: a watched Downloads folder would otherwise turn
+ // every installer into a picture, and "Move to originals" would move the installer away.
+ if (FormatRegistry.Detect(path) is null || FormatRegistry.IsProgram(path))
return true;
if (!FileNameFilter.Of(rule.Include).Matches(name))
return true;
diff --git a/src/Filee.Engines/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs
index 6ddc9f2cc..1640d3914 100644
--- a/src/Filee.Engines/Magick/ImageEncoder.cs
+++ b/src/Filee.Engines/Magick/ImageEncoder.cs
@@ -2,6 +2,7 @@
// Used by MagickImageConverter and by PdfiumConverter (PDF pages → images).
using Filee.Core.Conversion;
+using Filee.Core.Formats;
using Filee.Core.Presets;
using ImageMagick;
@@ -82,14 +83,16 @@ public static bool KeepsAnimation(string sourceFormat, string targetFormat) =>
public static readonly HashSet ExplicitCoder =
["mng", "qoi", "dds", "pcx", "cur", "xpm", "xbm", "wbmp", "pam", "pfm", "sgi", "ras", "miff", "dcm", "pcd", "exr", "hdr"];
- /// Formats holding one picture in several sizes (icons) or mipmaps: the largest is converted.
- private static readonly HashSet SizeVariants = ["ico", "cur", "dds"];
+ ///
+ /// Formats holding one picture in several sizes (icons, the icon of a program) or mipmaps: the largest is converted.
+ ///
+ private static readonly HashSet SizeVariants = ["ico", "cur", "dds", FormatRegistry.Program];
///
/// Packages of a picture and more, of which the picture is read (): an iPhone LIVP, a Krita
- /// KRA.
+ /// KRA, the icon of a Windows program or library ().
///
- public static readonly string[] Packages = ["livp", "kra"];
+ public static readonly string[] Packages = ["livp", "kra", FormatRegistry.Program];
///
/// Every format ImageMagick reads as input: the raster formats, the picture of and, on
@@ -158,8 +161,8 @@ public static (MagickFormat Format, string Extension) OutputFormat(string target
///
/// Chooses which frames of a (possibly multi-frame) source are converted.
- /// ICO, CUR, DDS: the largest image. Animations (GIF, WebP, APNG, MNG, JPEG XL, AVIF): every frame for an animated or
- /// TIFF target, else the first frame.
+ /// ICO, CUR, DDS, a program's icon: the largest image. Animations (GIF, WebP, APNG, MNG, JPEG XL, AVIF): every frame
+ /// for an animated or TIFF target, else the first frame.
/// PSD/PSB: the composite (frame 0; any further frames are layers). Otherwise: all frames/pages.
///
public static List> SelectFrames(MagickImageCollection images, string sourceFormat, string targetFormat)
diff --git a/src/Filee.Engines/Magick/ImageReader.cs b/src/Filee.Engines/Magick/ImageReader.cs
index c04f048a4..eba20fb9b 100644
--- a/src/Filee.Engines/Magick/ImageReader.cs
+++ b/src/Filee.Engines/Magick/ImageReader.cs
@@ -2,10 +2,11 @@
// (LibRaw settings), layered PSD/PSB/XCF (one flattened picture instead of one file per layer) and Windows
// metafiles (rendered at the preset's DPI), APNG (Filee's own reader composes the frames) and uncompressed DICOM
// (Filee's own reader, as ImageMagick's Q8 build reads only 8-bit DICOM), FITS (Filee's own reader, stretched and
-// debayered as the preset says), the photo of an iPhone LIVP and the merged picture of a Krita KRA. Used by every engine
-// that takes a raster image as input.
+// debayered as the preset says), the photo of an iPhone LIVP, the merged picture of a Krita KRA and the icon of a Windows
+// program. Used by every engine that takes a raster image as input.
using System.Buffers.Binary;
+using Filee.Core.Formats;
using Filee.Core.Presets;
using ImageMagick;
using ImageMagick.Formats;
@@ -32,6 +33,8 @@ public static MagickImageCollection Read(string path, string formatId, Preset pr
return ReadLivePhoto(path, preset);
if (formatId == "kra")
return ReadPackaged(Krita.ReadMerged(path), "png", preset);
+ if (formatId == FormatRegistry.Program)
+ return ReadPackaged(ProgramIcon.Read(path), "ico", preset);
if (formatId == "fits")
return [FitsRendering.ToMagick(Fits.Read(path), preset.Image.FitsDebayer)];
diff --git a/src/Filee.Engines/Magick/MagickImageConverter.cs b/src/Filee.Engines/Magick/MagickImageConverter.cs
index d676fa400..6752a7b0b 100644
--- a/src/Filee.Engines/Magick/MagickImageConverter.cs
+++ b/src/Filee.Engines/Magick/MagickImageConverter.cs
@@ -2,6 +2,7 @@
// FFmpeg when it is installed (Media/AvifSequenceWriter), much faster than ImageMagick's own AVIF sequence writer.
using Filee.Core.Conversion;
+using Filee.Core.Formats;
using Filee.Engines.Infrastructure;
using Filee.Engines.Media;
using ImageMagick;
@@ -89,6 +90,9 @@ public Task> ConvertAsync(ConversionStep step, IProgressOne size of an icon: a 1024 px 32-bit BMP is 4 MB, a PNG of that size much less.
+ private const int MaxImageBytes = 16 * 1024 * 1024;
+
+ /// All sizes of the icon together.
+ private const long MaxIconBytes = 64L * 1024 * 1024;
+
+ /// Entries of one resource directory. Real files have a few hundred at most.
+ private const int MaxDirectoryEntries = 16_384;
+
+ ///
+ /// The program's icon as the bytes of an .ico file, with every size it has.
+ ///
+ ///
+ /// error.exe_unreadable for a file that isn't a 32 or 64-bit Windows program or library, or is cut short;
+ /// error.exe_no_icon for one without an icon of its own.
+ ///
+ public static byte[] Read(string path)
+ {
+ using var handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.Read | FileShare.Delete);
+ return new Reader(handle).Icon();
+ }
+
+ ///
+ /// True when a program's icon goes to ICO as it is, every size the program has: the preset changes nothing about
+ /// the picture and asks for the usual sizes. Otherwise the icon is made again from its largest size, as for any
+ /// picture.
+ ///
+ public static bool KeptAsItIs(ImageOptions options) =>
+ !options.EditsPicture && !options.ReworksPixels && options.MaxSizeMb <= 0 && options.MinSizeMb <= 0
+ && options.IcoSizes.ToHashSet().SetEquals(new ImageOptions().IcoSizes);
+
+ /// Writes the program's icon unchanged to an .ico file.
+ public static IReadOnlyList Save(ConversionStep step, IProgress? progress)
+ {
+ var icon = Read(step.InputPath);
+ progress?.Report(0.5);
+ var path = step.Output.Allocate("ico");
+ if (path is null)
+ return [];
+ File.WriteAllBytes(path, icon);
+ progress?.Report(1);
+ return [path];
+ }
+
+ private sealed class Reader(SafeFileHandle handle)
+ {
+ private readonly long _length = RandomAccess.GetLength(handle);
+ private readonly List<(uint Address, uint VirtualSize, uint RawSize, uint RawOffset)> _sections = [];
+ private uint _resources;
+
+ public byte[] Icon()
+ {
+ var resources = Headers();
+ if (resources == 0)
+ throw new ConversionRefusedException("error.exe_no_icon");
+ _resources = resources;
+
+ var types = Directory(0);
+ var groups = Subdirectory(types, GroupIconType);
+ var icons = Subdirectory(types, IconType);
+ if (groups is null || icons is null)
+ throw new ConversionRefusedException("error.exe_no_icon");
+ var names = Directory(groups.Value);
+ if (names.Count == 0)
+ throw new ConversionRefusedException("error.exe_no_icon");
+
+ // The first group is the icon Explorer shows: named groups come first, then numbered ones in order, as the
+ // directory stores them.
+ var group = Data(names[0]);
+ var images = new Dictionary();
+ foreach (var entry in Directory(icons.Value).Where(e => !e.IsName))
+ images.TryAdd(entry.Id, entry);
+ return Assemble(group, images);
+ }
+
+ /// Checks the MZ and PE headers, reads the section table and returns the resources' address (0: none).
+ private uint Headers()
+ {
+ var dos = Read(0, 0x40);
+ if (dos[0] != 'M' || dos[1] != 'Z')
+ throw Unreadable();
+ var pe = (long)BinaryPrimitives.ReadUInt32LittleEndian(dos.AsSpan(0x3c));
+ var file = Read(pe, 24);
+ if (!file.AsSpan(0, 4).SequenceEqual("PE\0\0"u8))
+ throw Unreadable(); // an MS-DOS or 16-bit Windows program (NE) has no PE header
+ var sections = BinaryPrimitives.ReadUInt16LittleEndian(file.AsSpan(6));
+ var optionalSize = BinaryPrimitives.ReadUInt16LittleEndian(file.AsSpan(20));
+ if (sections == 0 || optionalSize < 2)
+ throw Unreadable();
+
+ var optional = Read(pe + 24, optionalSize);
+ // PE32 and PE32+ differ in where the data directories begin; the resource table is the third of them.
+ var (countAt, directoriesAt) = BinaryPrimitives.ReadUInt16LittleEndian(optional) switch
+ {
+ 0x10b => (92, 96),
+ 0x20b => (108, 112),
+ _ => throw Unreadable(),
+ };
+ var table = Read(pe + 24 + optionalSize, sections * 40);
+ for (var i = 0; i < sections; i++)
+ {
+ var section = table.AsSpan(i * 40);
+ _sections.Add((BinaryPrimitives.ReadUInt32LittleEndian(section[12..]), BinaryPrimitives.ReadUInt32LittleEndian(section[8..]),
+ BinaryPrimitives.ReadUInt32LittleEndian(section[16..]), BinaryPrimitives.ReadUInt32LittleEndian(section[20..])));
+ }
+ if (optionalSize < directoriesAt + 3 * 8 || BinaryPrimitives.ReadUInt32LittleEndian(optional.AsSpan(countAt)) < 3)
+ return 0;
+ return BinaryPrimitives.ReadUInt32LittleEndian(optional.AsSpan(directoriesAt + 2 * 8));
+ }
+
+ /// The entries of the resource directory at into the resources.
+ private List Directory(uint offset)
+ {
+ var header = Resource(offset, 16);
+ var count = BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(12)) + BinaryPrimitives.ReadUInt16LittleEndian(header.AsSpan(14));
+ if (count > MaxDirectoryEntries)
+ throw Unreadable();
+ var raw = Resource(offset + 16, count * 8);
+ var entries = new List(count);
+ for (var i = 0; i < count; i++)
+ {
+ var name = BinaryPrimitives.ReadUInt32LittleEndian(raw.AsSpan(i * 8));
+ var target = BinaryPrimitives.ReadUInt32LittleEndian(raw.AsSpan(i * 8 + 4));
+ entries.Add(new Entry((name & 0x8000_0000) != 0, name & 0x7fff_ffff, (target & 0x8000_0000) != 0, target & 0x7fff_ffff));
+ }
+ return entries;
+ }
+
+ /// The subdirectory of the resource type , or null when the file has none.
+ private static uint? Subdirectory(List types, uint type) =>
+ types.FirstOrDefault(e => !e.IsName && e.Id == type && e.IsDirectory) is { IsDirectory: true } entry ? entry.Target : null;
+
+ ///
+ /// The data of a resource: below its name is a directory of languages, and the first language is taken (a program
+ /// rarely has its icon in more than one).
+ ///
+ private byte[] Data(Entry entry)
+ {
+ if (entry.IsDirectory)
+ {
+ var languages = Directory(entry.Target);
+ // Two levels below a type is the data; a directory there would lead further and is no resource file's.
+ entry = languages.FirstOrDefault(e => !e.IsDirectory) ?? throw Unreadable();
+ }
+ var description = Resource(entry.Target, 16);
+ var address = BinaryPrimitives.ReadUInt32LittleEndian(description);
+ var size = BinaryPrimitives.ReadUInt32LittleEndian(description.AsSpan(4));
+ if (size > MaxImageBytes)
+ throw Unreadable();
+ return Read(Offset(address, size), (int)size);
+ }
+
+ ///
+ /// An .ico file from a group: the same header and size list, with each size's place in the file instead of its
+ /// resource number, then the pictures.
+ ///
+ private byte[] Assemble(byte[] group, Dictionary images)
+ {
+ if (group.Length < 6 || BinaryPrimitives.ReadUInt16LittleEndian(group.AsSpan(2)) != 1)
+ throw Unreadable();
+ var count = BinaryPrimitives.ReadUInt16LittleEndian(group.AsSpan(4));
+ if (group.Length < 6 + count * 14)
+ throw Unreadable();
+
+ var sizes = new List<(byte[] Entry, byte[] Picture)>();
+ long total = 0;
+ for (var i = 0; i < count; i++)
+ {
+ var entry = group.AsSpan(6 + i * 14, 14);
+ // A size whose picture is missing is left out, as Windows does when it draws the icon.
+ if (!images.TryGetValue(BinaryPrimitives.ReadUInt16LittleEndian(entry[12..]), out var image))
+ continue;
+ var picture = Data(image);
+ if (picture.Length == 0)
+ continue;
+ total += picture.Length;
+ if (total > MaxIconBytes)
+ throw Unreadable();
+ sizes.Add((entry[..12].ToArray(), picture));
+ }
+ if (sizes.Count == 0)
+ throw new ConversionRefusedException("error.exe_no_icon");
+
+ var ico = new byte[6 + sizes.Count * 16 + total];
+ BinaryPrimitives.WriteUInt16LittleEndian(ico.AsSpan(2), 1);
+ BinaryPrimitives.WriteUInt16LittleEndian(ico.AsSpan(4), (ushort)sizes.Count);
+ var at = 6 + sizes.Count * 16;
+ for (var i = 0; i < sizes.Count; i++)
+ {
+ var (entry, picture) = sizes[i];
+ var target = ico.AsSpan(6 + i * 16, 16);
+ // Width, height, colours, reserved, planes and bits per pixel are the same as in the group.
+ entry.AsSpan(0, 8).CopyTo(target);
+ BinaryPrimitives.WriteUInt32LittleEndian(target[8..], (uint)picture.Length);
+ BinaryPrimitives.WriteUInt32LittleEndian(target[12..], (uint)at);
+ picture.CopyTo(ico.AsSpan(at));
+ at += picture.Length;
+ }
+ return ico;
+ }
+
+ /// bytes at into the resource directory.
+ private byte[] Resource(uint offset, int count) => Read(Offset(_resources + (long)offset, (uint)count), count);
+
+ /// Where in the file the bytes at a virtual address are, when all of them are stored there.
+ private long Offset(long address, uint count)
+ {
+ foreach (var (start, virtualSize, rawSize, rawOffset) in _sections)
+ {
+ if (address < start || address >= start + (long)Math.Max(virtualSize, rawSize))
+ continue;
+ var into = address - start;
+ // Past the stored part a section is zeros in memory only: such data isn't in the file.
+ if (into + count > rawSize)
+ throw Unreadable();
+ return rawOffset + into;
+ }
+ throw Unreadable();
+ }
+
+ private byte[] Read(long offset, int count)
+ {
+ if (offset < 0 || count < 0 || offset + count > _length)
+ throw Unreadable();
+ var data = new byte[count];
+ for (var read = 0; read < count;)
+ {
+ var got = RandomAccess.Read(handle, data.AsSpan(read), offset + read);
+ if (got == 0)
+ throw Unreadable();
+ read += got;
+ }
+ return data;
+ }
+
+ private static ConversionRefusedException Unreadable() => new("error.exe_unreadable");
+ }
+
+ /// One entry of a resource directory: a type, name or language, and what it leads to.
+ private sealed record Entry(bool IsName, uint Id, bool IsDirectory, uint Target);
+}
diff --git a/tests/Filee.Cli.Tests/CliTests.cs b/tests/Filee.Cli.Tests/CliTests.cs
index 65a38e091..fa74b2169 100644
--- a/tests/Filee.Cli.Tests/CliTests.cs
+++ b/tests/Filee.Cli.Tests/CliTests.cs
@@ -376,6 +376,8 @@ public async Task A_folder_converts_the_files_filee_knows()
{
Png("a.png");
File.WriteAllText(Path.Combine(_folder, "notes.unknownformat"), "x");
+ // Filee reads a program's icon only when the program is named, not every installer in a folder (#1188).
+ File.WriteAllBytes(Path.Combine(_folder, "setup.exe"), "MZ"u8.ToArray());
var (code, output, _) = await Run("convert", _folder, "--preset", BuiltInData.CreatePresets().First(p => p.TargetFormat == "jpg").Id);
diff --git a/tests/Filee.Core.Tests/FolderWatcherTests.cs b/tests/Filee.Core.Tests/FolderWatcherTests.cs
index 8ab83359c..05d908fe0 100644
--- a/tests/Filee.Core.Tests/FolderWatcherTests.cs
+++ b/tests/Filee.Core.Tests/FolderWatcherTests.cs
@@ -375,6 +375,8 @@ public async Task A_burst_converts_every_file_also_when_some_fail_for_a_moment()
[InlineData("photo.jpg.crdownload")]
[InlineData("~$report.docx")]
[InlineData("sketch.kra~")] // Krita's backup of the previous save, written next to sketch.kra
+ [InlineData("setup.exe")] // programs only when dropped or named (#1188): a downloaded installer is no picture
+ [InlineData("plugin.DLL")]
[InlineData(".hidden.jpg")]
[InlineData("notes.unknownformat")]
[InlineData("converted/photo.jpg")]
diff --git a/tests/Filee.Core.Tests/FormatFieldsTests.cs b/tests/Filee.Core.Tests/FormatFieldsTests.cs
index 9539abba7..39c64a174 100644
--- a/tests/Filee.Core.Tests/FormatFieldsTests.cs
+++ b/tests/Filee.Core.Tests/FormatFieldsTests.cs
@@ -144,7 +144,7 @@ public void Grade_counts_match_the_published_table()
new Dictionary
{
[FormatGrade.Everyday] = 19,
- [FormatGrade.General] = 95,
+ [FormatGrade.General] = 96,
[FormatGrade.Specialist] = 115,
[FormatGrade.Legacy] = 51,
},
diff --git a/tests/Filee.Core.Tests/ProgramFormatTests.cs b/tests/Filee.Core.Tests/ProgramFormatTests.cs
new file mode 100644
index 000000000..eea61bc99
--- /dev/null
+++ b/tests/Filee.Core.Tests/ProgramFormatTests.cs
@@ -0,0 +1,41 @@
+// Windows programs and libraries (.exe, .dll; #1188) are read as their icon: they open the images donut, which existing
+// libraries learn without taking a donut of the user's own.
+
+using Filee.Core.Formats;
+using Filee.Core.Presets;
+using Filee.Core.Profiles;
+using Filee.Core.Settings;
+
+namespace Filee.Core.Tests;
+
+public class ProgramFormatTests
+{
+ [Theory]
+ [InlineData("setup.exe")]
+ [InlineData("SHELL32.DLL")]
+ public void A_program_uses_the_image_donut(string file)
+ {
+ Assert.Same(FormatRegistry.Get(FormatRegistry.Program), FormatRegistry.Detect(file));
+ Assert.Equal("images", ProfileSelector.Select(BuiltInData.CreateProfiles(), [file])?.Id);
+ }
+
+ [Fact]
+ public void Version_77_libraries_learn_exe_and_dll_without_taking_custom_claims()
+ {
+ var presets = BuiltInData.CreatePresets();
+ var profiles = BuiltInData.CreateProfiles();
+ var images = profiles.Single(p => p.Id == "images");
+ images.Extensions.RemoveAll(e => e is "exe" or "dll");
+ Assert.True(SettingsMigrations.ApplyToLibrary(77, presets, profiles));
+ Assert.Contains("exe", images.Extensions);
+ Assert.Contains("dll", images.Extensions);
+ Assert.False(SettingsMigrations.ApplyToLibrary(77, presets, profiles));
+
+ images.Extensions.RemoveAll(e => e is "exe" or "dll");
+ profiles.Insert(0, new ToolbarProfile { Id = "my-icons", Extensions = ["EXE"] });
+ Assert.True(SettingsMigrations.ApplyToLibrary(77, presets, profiles));
+ Assert.DoesNotContain("exe", images.Extensions); // the user's own donut has it
+ Assert.Contains("dll", images.Extensions);
+ Assert.Equal("my-icons", ProfileSelector.Select(profiles, ["setup.exe"])?.Id);
+ }
+}
diff --git a/tests/Filee.Engines.Tests/ProgramIconTests.cs b/tests/Filee.Engines.Tests/ProgramIconTests.cs
new file mode 100644
index 000000000..38647daf8
--- /dev/null
+++ b/tests/Filee.Engines.Tests/ProgramIconTests.cs
@@ -0,0 +1,324 @@
+// The icon of a Windows program or library (.exe, .dll; issue #1188). The programs are built here as a linker lays them
+// out, with only what Windows needs to find their icons: the MZ and PE headers, one .rsrc section and the resource tree
+// in it. Their icons come from ICO files ImageMagick writes, so a program's icon taken out as ICO can be compared with
+// the icon that went in.
+
+using System.Buffers.Binary;
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
+using Filee.Core.Presets;
+using ImageMagick;
+
+namespace Filee.Engines.Tests;
+
+public class ProgramIconTests(EngineFixture fx) : IClassFixture
+{
+ private const uint IconType = 3; // RT_ICON
+ private const uint GroupIconType = 14; // RT_GROUP_ICON
+ private const uint VersionType = 16; // RT_VERSION
+ private const uint SectionAddress = 0x1000;
+ private const int SectionOffset = 0x200;
+
+ ///
+ /// An .ico with one square picture per size, each in its own colour: a BMP below 256 px (as ImageMagick encodes it in
+ /// an icon), a PNG at 256, as in most programs. ImageMagick writes one size per file here, so the sizes are put
+ /// together by hand.
+ ///
+ private static byte[] Ico(params (uint Size, IMagickColor Colour)[] sizes)
+ {
+ var entries = new List<(byte[] Entry, byte[] Picture)>();
+ foreach (var (size, colour) in sizes)
+ {
+ using var image = new MagickImage(colour, size, size);
+ if (size >= 256)
+ {
+ // Width and height 0 stand for 256; one plane, 32 bits per pixel.
+ entries.Add(([0, 0, 0, 0, 1, 0, 32, 0, 0, 0, 0, 0], image.ToByteArray(MagickFormat.Png32)));
+ continue;
+ }
+ var single = image.ToByteArray(MagickFormat.Icon);
+ var length = (int)BinaryPrimitives.ReadUInt32LittleEndian(single.AsSpan(6 + 8));
+ var offset = (int)BinaryPrimitives.ReadUInt32LittleEndian(single.AsSpan(6 + 12));
+ entries.Add((single[6..(6 + 12)], single[offset..(offset + length)]));
+ }
+ var ico = new byte[6 + entries.Count * 16 + entries.Sum(e => e.Picture.Length)];
+ BinaryPrimitives.WriteUInt16LittleEndian(ico.AsSpan(2), 1);
+ BinaryPrimitives.WriteUInt16LittleEndian(ico.AsSpan(4), (ushort)entries.Count);
+ var at = 6 + entries.Count * 16;
+ for (var i = 0; i < entries.Count; i++)
+ {
+ entries[i].Entry.CopyTo(ico.AsSpan(6 + i * 16));
+ BinaryPrimitives.WriteUInt32LittleEndian(ico.AsSpan(6 + i * 16 + 8), (uint)entries[i].Picture.Length);
+ BinaryPrimitives.WriteUInt32LittleEndian(ico.AsSpan(6 + i * 16 + 12), (uint)at);
+ entries[i].Picture.CopyTo(ico.AsSpan(at));
+ at += entries[i].Picture.Length;
+ }
+ return ico;
+ }
+
+ /// The 16, 32 and 256 px icon most programs have: red, green and blue.
+ private static byte[] UsualIcon() => Ico((16, MagickColors.Red), (32, MagickColors.Lime), (256, MagickColors.Blue));
+
+ ///
+ /// The resources of a program with these icons: each .ico becomes an icon group (numbered 1, 2, … in order) and its
+ /// sizes icons (numbered on from 1 across the groups), as the resource compiler does with ICON statements.
+ ///
+ private static List<(uint Type, List<(uint Id, byte[] Data)> Items)> IconResources(params byte[][] icons)
+ {
+ var images = new List<(uint, byte[])>();
+ var groups = new List<(uint, byte[])>();
+ foreach (var ico in icons)
+ {
+ var count = BinaryPrimitives.ReadUInt16LittleEndian(ico.AsSpan(4));
+ var group = new byte[6 + count * 14];
+ ico.AsSpan(0, 6).CopyTo(group);
+ for (var i = 0; i < count; i++)
+ {
+ var entry = ico.AsSpan(6 + i * 16, 16);
+ var size = (int)BinaryPrimitives.ReadUInt32LittleEndian(entry[8..]);
+ var offset = (int)BinaryPrimitives.ReadUInt32LittleEndian(entry[12..]);
+ var id = (uint)images.Count + 1;
+ images.Add((id, ico.AsSpan(offset, size).ToArray()));
+ entry[..12].CopyTo(group.AsSpan(6 + i * 14));
+ BinaryPrimitives.WriteUInt16LittleEndian(group.AsSpan(6 + i * 14 + 12), (ushort)id);
+ }
+ groups.Add(((uint)groups.Count + 1, group));
+ }
+ return [(IconType, images), (GroupIconType, groups)];
+ }
+
+ ///
+ /// A resource section: the type directory, a directory of names per type and of languages (US English) per name,
+ /// the data descriptions and the data, at the addresses the section is loaded at.
+ ///
+ private static byte[] ResourceSection(List<(uint Type, List<(uint Id, byte[] Data)> Items)> types)
+ {
+ static int Directory(int entries) => 16 + 8 * entries;
+ var items = types.SelectMany(t => t.Items).ToList();
+ var nameDirectories = Directory(types.Count);
+ var languageDirectories = nameDirectories + types.Sum(t => Directory(t.Items.Count));
+ var descriptions = languageDirectories + items.Count * Directory(1);
+ var data = descriptions + items.Count * 16;
+ var section = new byte[data + items.Sum(i => (i.Data.Length + 3) & ~3)];
+
+ void WriteDirectory(int at, IReadOnlyList<(uint Id, int Target, bool IsDirectory)> entries)
+ {
+ BinaryPrimitives.WriteUInt16LittleEndian(section.AsSpan(at + 14), (ushort)entries.Count);
+ for (var i = 0; i < entries.Count; i++)
+ {
+ BinaryPrimitives.WriteUInt32LittleEndian(section.AsSpan(at + 16 + i * 8), entries[i].Id);
+ BinaryPrimitives.WriteUInt32LittleEndian(section.AsSpan(at + 20 + i * 8),
+ (uint)entries[i].Target | (entries[i].IsDirectory ? 0x8000_0000 : 0));
+ }
+ }
+
+ var typeEntries = new List<(uint, int, bool)>();
+ var nameAt = nameDirectories;
+ var item = 0;
+ foreach (var (type, list) in types)
+ {
+ typeEntries.Add((type, nameAt, true));
+ var nameEntries = new List<(uint, int, bool)>();
+ foreach (var (id, bytes) in list)
+ {
+ var languageAt = languageDirectories + item * Directory(1);
+ var descriptionAt = descriptions + item * 16;
+ nameEntries.Add((id, languageAt, true));
+ WriteDirectory(languageAt, [(1033u, descriptionAt, false)]);
+ BinaryPrimitives.WriteUInt32LittleEndian(section.AsSpan(descriptionAt), SectionAddress + (uint)data);
+ BinaryPrimitives.WriteUInt32LittleEndian(section.AsSpan(descriptionAt + 4), (uint)bytes.Length);
+ bytes.CopyTo(section.AsSpan(data));
+ data += (bytes.Length + 3) & ~3;
+ item++;
+ }
+ WriteDirectory(nameAt, nameEntries);
+ nameAt += Directory(list.Count);
+ }
+ WriteDirectory(0, typeEntries);
+ return section;
+ }
+
+ /// A program (or with a DLL) whose only section holds these resources.
+ private static byte[] Program(List<(uint Type, List<(uint Id, byte[] Data)> Items)>? resources, bool pe32 = false,
+ bool library = false)
+ {
+ var section = resources is null ? new byte[16] : ResourceSection(resources);
+ var rawSize = (section.Length + 0x1ff) & ~0x1ff;
+ var file = new byte[SectionOffset + rawSize];
+ "MZ"u8.CopyTo(file);
+ BinaryPrimitives.WriteUInt32LittleEndian(file.AsSpan(0x3c), 0x40);
+ "PE\0\0"u8.CopyTo(file.AsSpan(0x40));
+ var optionalSize = pe32 ? 224 : 240;
+ BinaryPrimitives.WriteUInt16LittleEndian(file.AsSpan(0x44), pe32 ? (ushort)0x14c : (ushort)0x8664);
+ BinaryPrimitives.WriteUInt16LittleEndian(file.AsSpan(0x46), 1);
+ BinaryPrimitives.WriteUInt16LittleEndian(file.AsSpan(0x54), (ushort)optionalSize);
+ BinaryPrimitives.WriteUInt16LittleEndian(file.AsSpan(0x56), library ? (ushort)0x2022 : (ushort)0x22);
+ var optional = file.AsSpan(0x58, optionalSize);
+ BinaryPrimitives.WriteUInt16LittleEndian(optional, pe32 ? (ushort)0x10b : (ushort)0x20b);
+ var directories = pe32 ? 96 : 112;
+ BinaryPrimitives.WriteUInt32LittleEndian(optional[(directories - 4)..], 16);
+ if (resources is not null)
+ {
+ BinaryPrimitives.WriteUInt32LittleEndian(optional[(directories + 16)..], SectionAddress);
+ BinaryPrimitives.WriteUInt32LittleEndian(optional[(directories + 20)..], (uint)section.Length);
+ }
+ var header = file.AsSpan(0x58 + optionalSize, 40);
+ ".rsrc"u8.CopyTo(header);
+ BinaryPrimitives.WriteUInt32LittleEndian(header[8..], (uint)section.Length);
+ BinaryPrimitives.WriteUInt32LittleEndian(header[12..], SectionAddress);
+ BinaryPrimitives.WriteUInt32LittleEndian(header[16..], (uint)rawSize);
+ BinaryPrimitives.WriteUInt32LittleEndian(header[20..], SectionOffset);
+ section.CopyTo(file.AsSpan(SectionOffset));
+ return file;
+ }
+
+ private string Save(byte[] file, string name = "tool.exe")
+ {
+ var path = Path.Combine(fx.NewFolder(), name);
+ File.WriteAllBytes(path, file);
+ return path;
+ }
+
+ private static string Failures(ConversionJob job) => string.Join("; ", job.Files.Select(f => $"{f.ErrorKey}: {f.ErrorDetail}"));
+
+ private static IMagickColor? Colour(string path)
+ {
+ using var image = new MagickImage(path);
+ using var pixels = image.GetPixels();
+ return pixels.GetPixel((int)image.Width / 2, (int)image.Height / 2)!.ToColor();
+ }
+
+ [Theory]
+ [InlineData("tool.exe")]
+ [InlineData("SHELL32.DLL")]
+ public void Programs_and_libraries_are_read_as_their_icon_and_never_written(string name)
+ {
+ var format = FormatRegistry.Detect(name)!;
+ Assert.Equal(FormatRegistry.Program, format.Id);
+ Assert.Equal(FormatCategory.Image, format.Category);
+ Assert.False(format.Writable);
+ Assert.True(FormatRegistry.IsProgram(name));
+ Assert.False(FormatRegistry.IsProgram("tool.ico"));
+ }
+
+ [Theory]
+ [InlineData(false, "tool.exe")]
+ [InlineData(true, "tool.exe")]
+ [InlineData(false, "shell.dll")]
+ public async Task A_program_becomes_a_png_of_its_largest_icon(bool pe32, string name)
+ {
+ var program = Save(Program(IconResources(UsualIcon()), pe32, library: name.EndsWith(".dll")), name);
+
+ var job = await fx.ConvertAsync([program], new Preset { TargetFormat = "png" });
+
+ Assert.True(job.State == JobState.Completed, Failures(job));
+ var output = Assert.Single(job.Outputs);
+ Assert.Equal(Path.ChangeExtension(name, "png"), Path.GetFileName(output));
+ using var png = new MagickImage(output);
+ Assert.Equal((256u, 256u), (png.Width, png.Height));
+ Assert.Equal(MagickColors.Blue, Colour(output));
+ }
+
+ [Fact]
+ public async Task A_program_becomes_its_own_icon_as_ico_with_every_size()
+ {
+ var icon = UsualIcon();
+ var program = Save(Program(IconResources(icon)));
+
+ var job = await fx.ConvertAsync([program], new Preset { TargetFormat = "ico" });
+
+ Assert.True(job.State == JobState.Completed, Failures(job));
+ // The same file that went into the program: no size is made again from another.
+ var output = Assert.Single(job.Outputs);
+ Assert.Equal(icon, File.ReadAllBytes(output));
+ using var sizes = new MagickImageCollection(output);
+ Assert.Equal([16u, 32u, 256u], sizes.Select(i => i.Width).Order());
+ // The small sizes stay BMPs and the large one a PNG.
+ byte[] png = [0x89, (byte)'P', (byte)'N', (byte)'G'];
+ var pngs = Enumerable.Range(0, 3)
+ .Count(i => icon.AsSpan((int)BinaryPrimitives.ReadUInt32LittleEndian(icon.AsSpan(6 + i * 16 + 12))).StartsWith(png));
+ Assert.Equal(1, pngs);
+ }
+
+ [Fact]
+ public async Task Other_icon_sizes_make_the_icon_again_from_its_largest_size()
+ {
+ var program = Save(Program(IconResources(UsualIcon())));
+ var preset = new Preset { TargetFormat = "ico" };
+ preset.Image.IcoSizes = [48, 24];
+
+ var job = await fx.ConvertAsync([program], preset);
+
+ Assert.True(job.State == JobState.Completed, Failures(job));
+ using var ico = new MagickImageCollection(Assert.Single(job.Outputs));
+ Assert.Equal([48u, 24u], ico.Select(i => i.Width).OrderDescending());
+ // Both from the 256 px picture, not the 32 px one.
+ Assert.All(ico, i => Assert.Equal(MagickColors.Blue, i.GetPixels().GetPixel((int)i.Width / 2, (int)i.Height / 2)!.ToColor()));
+ }
+
+ [Fact]
+ public async Task The_first_icon_group_is_taken_as_explorer_shows_it()
+ {
+ var shown = Ico((32, MagickColors.Red), (48, MagickColors.Red));
+ var other = Ico((64, MagickColors.Lime));
+ var program = Save(Program(IconResources(shown, other)));
+
+ var job = await fx.ConvertAsync([program], new Preset { TargetFormat = "png" });
+
+ Assert.True(job.State == JobState.Completed, Failures(job));
+ var output = Assert.Single(job.Outputs);
+ using var png = new MagickImage(output);
+ Assert.Equal(48u, png.Width);
+ Assert.Equal(MagickColors.Red, Colour(output));
+ }
+
+ [Fact]
+ public async Task A_size_whose_picture_is_missing_is_left_out()
+ {
+ var resources = IconResources(UsualIcon());
+ resources[0].Items.RemoveAt(2); // the 256 px picture
+
+ var job = await fx.ConvertAsync([Save(Program(resources))], new Preset { TargetFormat = "png" });
+
+ Assert.True(job.State == JobState.Completed, Failures(job));
+ var output = Assert.Single(job.Outputs);
+ Assert.Equal(MagickColors.Lime, Colour(output));
+ }
+
+ public static TheoryData NoIcon => new() { "no resources", "other resources" };
+
+ [Theory]
+ [MemberData(nameof(NoIcon))]
+ public async Task A_program_without_an_icon_of_its_own_says_so(string kind)
+ {
+ var resources = kind == "no resources" ? null : new List<(uint, List<(uint, byte[])>)> { (VersionType, [(1, new byte[64])]) };
+ var job = await fx.ConvertAsync([Save(Program(resources))], new Preset { TargetFormat = "png" });
+
+ var file = Assert.Single(job.Files);
+ Assert.Equal(FileState.Failed, file.State);
+ Assert.Equal("error.exe_no_icon", file.ErrorKey);
+ }
+
+ public static TheoryData Unreadable => new() { "cut short", "not a program", "16-bit", "too short for a header" };
+
+ [Theory]
+ [MemberData(nameof(Unreadable))]
+ public async Task A_file_that_isnt_a_readable_program_says_so(string kind)
+ {
+ var program = Program(IconResources(UsualIcon()));
+ var file = kind switch
+ {
+ // The icons run past the end of the file.
+ "cut short" => program[..(SectionOffset + 300)],
+ "not a program" => "This is a text file renamed to .exe"u8.ToArray(),
+ "16-bit" => [.. program[..0x40], .. "NE"u8.ToArray(), .. new byte[0x200]],
+ _ => "MZ"u8.ToArray(),
+ };
+
+ var job = await fx.ConvertAsync([Save(file)], new Preset { TargetFormat = "png" });
+
+ var result = Assert.Single(job.Files);
+ Assert.Equal(FileState.Failed, result.State);
+ Assert.Equal("error.exe_unreadable", result.ErrorKey);
+ }
+}