From f027e6ca24fe717d58296878cbf27dedeab039c1 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Wed, 11 Nov 2026 00:10:50 +0900 Subject: [PATCH] feat(image): read the icon of Windows programs and libraries (#1188) A dropped .exe or .dll is read as the icon Explorer shows for it: the first RT_GROUP_ICON of its resources, put back together as an .ico in memory by Filee's own PE reader (ProgramIcon). Only the headers and the icon are read; the file is never loaded or run. - ICO is the icon as the program stores it, every size it has, unless the preset changes the picture or asks for other icon sizes; PNG and every other picture target use the largest size. - No icon of its own and unreadable/16-bit/damaged files give translated errors (error.exe_no_icon, error.exe_unreadable) in all 22 languages. - Programs are converted only when dropped or named: watch folders and CLI folder inputs skip them, and Recent conversions never opens one. - Settings schema 77: the images donut learns exe and dll; custom claims stay put. (Schema 77 may be renumbered if another change takes it first.) Co-Authored-By: Claude Opus 5.5 --- docs/ENGINES.md | 9 +- src/Filee.App/Assets/i18n/ar.json | 2 + src/Filee.App/Assets/i18n/bn-BD.json | 2 + src/Filee.App/Assets/i18n/de.json | 2 + src/Filee.App/Assets/i18n/en.json | 2 + src/Filee.App/Assets/i18n/es.json | 2 + src/Filee.App/Assets/i18n/fa.json | 2 + src/Filee.App/Assets/i18n/fr.json | 2 + src/Filee.App/Assets/i18n/hi.json | 2 + src/Filee.App/Assets/i18n/id.json | 2 + src/Filee.App/Assets/i18n/it.json | 2 + src/Filee.App/Assets/i18n/ja.json | 2 + src/Filee.App/Assets/i18n/ko.json | 2 + src/Filee.App/Assets/i18n/ne.json | 2 + src/Filee.App/Assets/i18n/pl.json | 2 + src/Filee.App/Assets/i18n/pt-BR.json | 2 + src/Filee.App/Assets/i18n/pt-PT.json | 2 + src/Filee.App/Assets/i18n/ru.json | 2 + src/Filee.App/Assets/i18n/th.json | 2 + src/Filee.App/Assets/i18n/tr.json | 2 + src/Filee.App/Assets/i18n/vi.json | 2 + src/Filee.App/Assets/i18n/zh-CN.json | 2 + src/Filee.App/Assets/i18n/zh-TW.json | 2 + .../ViewModels/Pages/HomePageViewModel.cs | 7 +- src/Filee.Cli/Cli.cs | 6 +- src/Filee.Core/Formats/FormatFields.cs | 2 +- src/Filee.Core/Formats/FormatRegistry.cs | 13 + src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 4 + src/Filee.Core/Watching/FolderWatcher.cs | 8 +- src/Filee.Engines/Magick/ImageEncoder.cs | 15 +- src/Filee.Engines/Magick/ImageReader.cs | 7 +- .../Magick/MagickImageConverter.cs | 4 + src/Filee.Engines/Magick/ProgramIcon.cs | 259 ++++++++++++++ tests/Filee.Cli.Tests/CliTests.cs | 2 + tests/Filee.Core.Tests/FolderWatcherTests.cs | 2 + tests/Filee.Core.Tests/FormatFieldsTests.cs | 2 +- tests/Filee.Core.Tests/ProgramFormatTests.cs | 41 +++ tests/Filee.Engines.Tests/ProgramIconTests.cs | 324 ++++++++++++++++++ 39 files changed, 731 insertions(+), 20 deletions(-) create mode 100644 src/Filee.Engines/Magick/ProgramIcon.cs create mode 100644 tests/Filee.Core.Tests/ProgramFormatTests.cs create mode 100644 tests/Filee.Engines.Tests/ProgramIconTests.cs diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 9181fe731..50db841c3 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 | @@ -328,6 +328,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 80782389b..cc08b0dff 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -162,6 +162,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 ce08b2d8e..6872a6dec 100644 --- a/src/Filee.App/Assets/i18n/bn-BD.json +++ b/src/Filee.App/Assets/i18n/bn-BD.json @@ -161,6 +161,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 8b860e163..2387a06c5 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -162,6 +162,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 4b5b251b7..f166bfd5c 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -168,6 +168,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 f16375452..99f20ad5d 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -162,6 +162,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 2705f457b..ee0b34388 100644 --- a/src/Filee.App/Assets/i18n/fa.json +++ b/src/Filee.App/Assets/i18n/fa.json @@ -161,6 +161,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 4600dda58..4ef0814c7 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -168,6 +168,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 1475c8fbf..ebe104154 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -162,6 +162,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 10dbe3f76..37eb40c5e 100644 --- a/src/Filee.App/Assets/i18n/id.json +++ b/src/Filee.App/Assets/i18n/id.json @@ -162,6 +162,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 dd168d33e..326094d58 100644 --- a/src/Filee.App/Assets/i18n/it.json +++ b/src/Filee.App/Assets/i18n/it.json @@ -161,6 +161,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 35e5cda18..9e5f28b36 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -162,6 +162,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 9e17dd741..e4dc09b72 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -168,6 +168,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 4992f8b98..03553cef2 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -162,6 +162,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 119b0b7ea..4a14a7fe0 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -168,6 +168,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 d42450243..e53a1fe27 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -168,6 +168,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 2fadfdea9..82680e195 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -168,6 +168,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 41e8b5fbf..4b2dd6d53 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -168,6 +168,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 4eefda857..364025320 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -162,6 +162,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 c63d7f7e1..68ddae38e 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -162,6 +162,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 3500f7f42..b5dbd0f93 100644 --- a/src/Filee.App/Assets/i18n/vi.json +++ b/src/Filee.App/Assets/i18n/vi.json @@ -168,6 +168,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 bd63aa656..ca31d1fea 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -168,6 +168,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 9841d7bbd..5cdc3ec46 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -168,6 +168,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 c9a58f833..b7209d2b1 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 5776e9f9f..545fec90c 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), @@ -397,6 +404,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 635731884..d28dcdf10 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 = 76; + public const int CurrentSchemaVersion = 77; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index 6fe50a224..bd9f71bc9 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -1081,6 +1081,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 176ab2b38..5b9e1b86d 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] = 114, [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..9c5444832 --- /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_76_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(76, presets, profiles)); + Assert.Contains("exe", images.Extensions); + Assert.Contains("dll", images.Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(76, 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(76, 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); + } +}