From b385fa7439c07981976037d8dee58d547ac9ff9e Mon Sep 17 00:00:00 2001 From: ema Date: Fri, 26 Dec 2025 23:46:29 +0800 Subject: [PATCH] Add EIF archive extraction with Face.dat ordering Introduced EifExtractor to support extracting QQ EIF emoji archives, reordering images based on Face.dat metadata. Updated CompoundFileExtractor with in-memory extraction, enhanced the plugin menu and extraction workflow to prompt for Face.dat ordering, and added translations for the new prompt in Translations.config. --- .../CompoundFileExtractor.cs | 76 ++++++++++++++- .../CompoundFileBinary/EifExtractor.cs | 95 +++++++++++++++++++ .../CompoundFileBinary/FaceDatDecoder.cs | 1 + .../Plugin.MoreMenu.cs | 60 +++++++++--- .../Translations.config | 29 ++++++ 5 files changed, 246 insertions(+), 15 deletions(-) create mode 100644 QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/EifExtractor.cs diff --git a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/CompoundFileExtractor.cs b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/CompoundFileExtractor.cs index 2d7967b..e99c77e 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/CompoundFileExtractor.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/CompoundFileExtractor.cs @@ -27,7 +27,7 @@ namespace QuickLook.Plugin.ArchiveViewer.CompoundFileBinary; /// Utility class to extract streams and storages from a COM compound file (IStorage) into the file system. /// This is a thin managed wrapper that enumerates entries inside the compound file and writes streams to disk. /// -public static class CompoundFileExtractor +public static partial class CompoundFileExtractor { /// /// Extracts all streams and storages from the compound file at @@ -142,3 +142,77 @@ public static class CompoundFileExtractor } } } + +/// +/// Utility class to extract streams and storages from a COM compound file (IStorage) into the memory. +/// This is a thin managed wrapper that enumerates entries inside the compound file and writes streams to dictionary. +/// +public static partial class CompoundFileExtractor +{ + /// + /// Extracts all streams from the compound file at + /// into a dictionary where the key is the relative path and the value is the file content. + /// + /// Path to the compound file (OLE compound file / structured storage). + /// A dictionary containing the extracted files. + public static Dictionary ExtractToDictionary(string compoundFilePath) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Ensure the compound file exists + if (!File.Exists(compoundFilePath)) + throw new FileNotFoundException("Compound file not found.", compoundFilePath); + + // Validate magic header for OLE compound file: D0 CF 11 E0 A1 B1 1A E1 + byte[] magicHeader = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + byte[] header = new byte[8]; + using (FileStream fs = new(compoundFilePath, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + int read = fs.Read(header, 0, header.Length); + if (read < header.Length || !header.SequenceEqual(magicHeader)) + { + throw new InvalidDataException("The specified file does not appear to be an OLE Compound File (invalid header)."); + } + } + + // Open the compound file as an IStorage implementation wrapped by DisposableIStorage. + using DisposableIStorage storage = new(compoundFilePath, STGM.DIRECT | STGM.READ | STGM.SHARE_EXCLUSIVE, IntPtr.Zero); + ExtractStorageToDictionary(storage, string.Empty, result); + + return result; + } + + private static void ExtractStorageToDictionary(DisposableIStorage storage, string currentPath, Dictionary result) + { + IEnumerator enumerator = storage.EnumElements(); + + // Enumerate all elements (streams and storages) at the root of the compound file. + while (enumerator.MoveNext()) + { + STATSTG entryStat = enumerator.Current; + string entryPath = string.IsNullOrEmpty(currentPath) ? entryStat.pwcsName : Path.Combine(currentPath, entryStat.pwcsName); + + // STGTY_STREAM indicates the element is a stream (treat as a file). + if (entryStat.type == (int)STGTY.STGTY_STREAM) + { + // Open the stream for reading from the compound file. + using DisposableIStream stream = storage.OpenStream(entryStat.pwcsName, IntPtr.Zero, STGM.READ | STGM.SHARE_EXCLUSIVE); + + // Query stream statistics to determine its size. + STATSTG streamStat = stream.Stat((int)STATFLAG.STATFLAG_DEFAULT); + + // Allocate a buffer exactly the size of the stream and read it in one call. + byte[] buffer = new byte[streamStat.cbSize]; + stream.Read(buffer, buffer.Length); + + result[entryPath] = buffer; + } + // STGTY_STORAGE indicates the element is a nested storage (treat as a directory). + else if (entryStat.type == (int)STGTY.STGTY_STORAGE) + { + using DisposableIStorage subStorage = storage.OpenStorage(entryStat.pwcsName, null, STGM.READ | STGM.SHARE_EXCLUSIVE, IntPtr.Zero); + ExtractStorageToDictionary(subStorage, entryPath, result); + } + } + } +} diff --git a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/EifExtractor.cs b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/EifExtractor.cs new file mode 100644 index 0000000..3b38622 --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/EifExtractor.cs @@ -0,0 +1,95 @@ +// Copyright © 2017-2025 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace QuickLook.Plugin.ArchiveViewer.CompoundFileBinary; + +/// +/// Utility to extract contents from an EIF archive and optionally reorder images +/// based on metadata stored in Face.dat. The EIF format is a Compound File Binary +/// (structured storage) used by QQ for emoji packs. +/// +public static class EifExtractor +{ + /// + /// File name of the Face.dat metadata stream inside EIF archives. + /// Face.dat contains mapping information used to order and rename images. + /// + public const string FaceDat = "Face.dat"; + + /// + /// Extracts files from the compound file at into + /// . If Face.dat exists inside the archive, + /// images will be renamed and reordered according to the mapping in Face.dat. + /// + /// Path to the EIF compound file. + /// Destination directory to write extracted files. + public static void ExtractToDirectory(string path, string outputDirectory) + { + // Extract all streams from the compound file into an in-memory dictionary + Dictionary compoundFile = CompoundFileExtractor.ExtractToDictionary(path); + + // If Face.dat exists, build mapping and reorder images accordingly + if (compoundFile.ContainsKey(FaceDat)) + { + // Build group -> (filename -> index) mapping from Face.dat + Dictionary> faceDat = FaceDatDecoder.Decode(compoundFile[FaceDat]); + + // Flatten mapping to key '\\' joined: "group\filename" -> index + Dictionary faceDatMapper = faceDat.SelectMany( + outer => outer.Value, + (outer, inner) => new { Key = $@"{outer.Key}\{inner.Key}", inner.Value }) + .ToDictionary(x => x.Key, x => x.Value); + + // Prepare output dictionary for files that match mapping + Dictionary output = []; + + foreach (var kv in faceDatMapper) + { + if (compoundFile.ContainsKey(kv.Key)) + { + // Create a new key using the index as file name and keep original extension + string newKey = Path.Combine(Path.GetDirectoryName(kv.Key), + faceDatMapper[kv.Key] + Path.GetExtension(kv.Key)); + + output[newKey] = compoundFile[kv.Key]; + } + } + + // Ensure target directory exists + Directory.CreateDirectory(outputDirectory); + + // Write each matched file to disk using its new name + foreach (var kv in output) + { + (string relativePath, byte[] data) = (kv.Key, kv.Value); + string fullPath = Path.Combine(outputDirectory, relativePath); + + // Ensure parent directory exists + string dir = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + // Write file bytes + File.WriteAllBytes(fullPath, data); + } + } + } +} diff --git a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/FaceDatDecoder.cs b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/FaceDatDecoder.cs index 94de1bd..71a99a3 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/FaceDatDecoder.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/FaceDatDecoder.cs @@ -29,6 +29,7 @@ namespace QuickLook.Plugin.ArchiveViewer.CompoundFileBinary; /// - Locates a repeating-key pattern and extracts the XOR-encrypted block /// - XOR-decodes the block and parses group\filename entries /// Provides a method to build the same group -> (filename -> index) mapping as the Python tool. +/// Reference: https://github.com/readme9txt/QQEIF-Extractor /// public static class FaceDatDecoder { diff --git a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/Plugin.MoreMenu.cs b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/Plugin.MoreMenu.cs index 788b275..f97c803 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/Plugin.MoreMenu.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/Plugin.MoreMenu.cs @@ -25,6 +25,7 @@ using System.Collections.Generic; using System.IO; using System.Reflection; using System.Threading.Tasks; +using System.Windows; using System.Windows.Input; using WindowsAPICodePack.Dialogs; @@ -32,17 +33,30 @@ namespace QuickLook.Plugin.ArchiveViewer; public partial class Plugin { + /// + /// Command to extract archive contents to a directory. Executed asynchronously. + /// public ICommand ExtractToDirectoryCommand { get; } + /// + /// Constructor - initializes commands used by the plugin. + /// public Plugin() { ExtractToDirectoryCommand = new AsyncRelayCommand(ExtractToDirectoryAsync); } + /// + /// Return additional "More" menu items for the plugin. + /// When the current file is an EIF archive, a menu item to extract to directory is provided. + /// public IEnumerable GetMenuItems() { - if (_path.EndsWith(".eif", StringComparison.OrdinalIgnoreCase)) + // Currently only supports for CFB and EIF files + if (_path.EndsWith(".cfb", StringComparison.OrdinalIgnoreCase) + || _path.EndsWith(".eif", StringComparison.OrdinalIgnoreCase)) { + // Use external Translations.config shipped next to the executing assembly string translationFile = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Translations.config"); yield return new MoreMenuItem() @@ -55,6 +69,10 @@ public partial class Plugin } } + /// + /// Show folder picker and extract archive contents to the chosen directory. + /// For EIF files, prompt the user whether to apply EIF-specific Face.dat ordering. + /// public async Task ExtractToDirectoryAsync() { using CommonOpenFileDialog dialog = new() @@ -64,25 +82,39 @@ public partial class Plugin if (dialog.ShowDialog() == CommonFileDialogResult.Ok) { - await Task.Run(() => + if (_path.EndsWith(".cfb", StringComparison.OrdinalIgnoreCase)) { - if (_path.EndsWith(".cfb", StringComparison.OrdinalIgnoreCase)) + // Generic compound file extraction + await Task.Run(() => { CompoundFileExtractor.ExtractToDirectory(_path, dialog.FileName); - } - else if (_path.EndsWith(".eif", StringComparison.OrdinalIgnoreCase)) + }); + } + else if (_path.EndsWith(".eif", StringComparison.OrdinalIgnoreCase)) + { + string translationFile = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Translations.config"); + + // Ask the user whether to apply EIF-specific `Face.dat` ordering during extraction + MessageBoxResult result = MessageBox.Show(TranslationHelper.Get("MW_ExtractToDirectory_EIFOrderFaceDat", + translationFile), "QuickLook", MessageBoxButton.YesNo, MessageBoxImage.Question); + + // If user chooses Yes, use EifExtractor which reorders images according to `Face.dat` + if (result == MessageBoxResult.Yes) { - CompoundFileExtractor.ExtractToDirectory(_path, dialog.FileName); - - string faceDatPath = Path.Combine(dialog.FileName, "face.dat"); - - if (File.Exists(faceDatPath)) + await Task.Run(() => { - Dictionary> faceDat = FaceDatDecoder.Decode(File.ReadAllBytes(faceDatPath)); - _ = faceDat; - } + EifExtractor.ExtractToDirectory(_path, dialog.FileName); + }); } - }); + else + { + // Fallback: generic compound file extraction + await Task.Run(() => + { + CompoundFileExtractor.ExtractToDirectory(_path, dialog.FileName); + }); + } + } } } } diff --git a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/Translations.config b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/Translations.config index 8019dba..a028a77 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/Translations.config +++ b/QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/Translations.config @@ -3,89 +3,118 @@ استخراج إلى المجلد + هل تريد الاحتفاظ بترتيب ملفات الرموز التعبيرية المخزنة؟ Extreu a la carpeta + Voleu mantenir l'ordre dels fitxers d'emoticones emmagatzemats? חלץ לתיקייה + האם ברצונך לשמור על סדר קבצי הרגשונים המאוחסנים? डायरेक्टरी में निकालें + क्या आप संग्रहीत इमोटिकॉन फ़ाइलों का क्रम बनाए रखना चाहते हैं? डायरेक्टरीमध्ये काढा + आपण संग्रहित इमोटिकॉन फायलींचा क्रम ठेवू इच्छिता? Kibontás mappába + Meg akarja tartani a tárolt hangulatjel-fájlok sorrendjét? Pakk ut til mappe + Vil du beholde rekkefølgen på lagrede uttrykksikonfiler? Uitpakken naar map + Wilt u de volgorde van opgeslagen emoticonbestanden behouden? Extrahovať do priečinka + Chcete zachovať poradie uložených súborov emotikonov? Ekstrak ke folder + Apakah Anda ingin menjaga urutan file emotikon yang tersimpan? Extract to directory + Do you want to keep the order of stored emoticon files? 폴더로 추출 + 이모티콘 파일의 저장 순서를 유지하시겠습니까? Extrair para a pasta + Deseja manter a ordem dos arquivos de emoticons armazenados? Extrair para a pasta + Deseja manter a ordem dos ficheiros de emoticons armazenados? Извлечь в папку + Вы хотите сохранить порядок сохраненных файлов смайликов? Klasöre çıkar + Saklanan ifade dosyalarının sırasını korumak istiyor musunuz? Trích xuất vào thư mục + Bạn có muốn giữ thứ tự của các tệp biểu tượng cảm xúc đã lưu không? Extrair para a pasta + Deseja manter a ordem dos ficheiros de emoticons armazenados? Extraire vers le dossier + Voulez-vous conserver l'ordre des fichiers d'émoticônes stockés ? Extraer a la carpeta + ¿Desea mantener el orden de los archivos de emoticonos almacenados? Estrai nella cartella + Vuoi mantenere l'ordine dei file di emoticon memorizzati? Wyodrębnij do folderu + Czy chcesz zachować kolejność przechowywanych plików emotikonów? Εξαγωγή σε φάκελο + Θέλετε να διατηρήσετε τη σειρά των αποθηκευμένων αρχείων emoticon; Розпакувати до папки + Ви хочете зберегти порядок збережених файлів смайлів? 提取到目录 + 是否需要保持表情文件存储的顺序? 提取到資料夾 + 是否需要保持表情檔案儲存的順序? フォルダに抽出 + 絵文字ファイルの保存順序を保持しますか? In Ordner extrahieren + Möchten Sie die Reihenfolge der gespeicherten Emoticon-Dateien beibehalten? Extrahera till mapp + Vill du behålla ordningen på lagrade uttryckssymbolfiler?