Skip to content

Commit b385fa7

Browse files
committed
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.
1 parent e7559f3 commit b385fa7

File tree

5 files changed

+245
-14
lines changed

5 files changed

+245
-14
lines changed

QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/CompoundFileExtractor.cs

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ namespace QuickLook.Plugin.ArchiveViewer.CompoundFileBinary;
2727
/// Utility class to extract streams and storages from a COM compound file (IStorage) into the file system.
2828
/// This is a thin managed wrapper that enumerates entries inside the compound file and writes streams to disk.
2929
/// </summary>
30-
public static class CompoundFileExtractor
30+
public static partial class CompoundFileExtractor
3131
{
3232
/// <summary>
3333
/// Extracts all streams and storages from the compound file at <paramref name="compoundFilePath"/>
@@ -142,3 +142,77 @@ private static void ExtractStorageToDirectory(DisposableIStorage storage, string
142142
}
143143
}
144144
}
145+
146+
/// <summary>
147+
/// Utility class to extract streams and storages from a COM compound file (IStorage) into the memory.
148+
/// This is a thin managed wrapper that enumerates entries inside the compound file and writes streams to dictionary.
149+
/// </summary>
150+
public static partial class CompoundFileExtractor
151+
{
152+
/// <summary>
153+
/// Extracts all streams from the compound file at <paramref name="compoundFilePath"/>
154+
/// into a dictionary where the key is the relative path and the value is the file content.
155+
/// </summary>
156+
/// <param name="compoundFilePath">Path to the compound file (OLE compound file / structured storage).</param>
157+
/// <returns>A dictionary containing the extracted files.</returns>
158+
public static Dictionary<string, byte[]> ExtractToDictionary(string compoundFilePath)
159+
{
160+
var result = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
161+
162+
// Ensure the compound file exists
163+
if (!File.Exists(compoundFilePath))
164+
throw new FileNotFoundException("Compound file not found.", compoundFilePath);
165+
166+
// Validate magic header for OLE compound file: D0 CF 11 E0 A1 B1 1A E1
167+
byte[] magicHeader = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
168+
byte[] header = new byte[8];
169+
using (FileStream fs = new(compoundFilePath, FileMode.Open, FileAccess.Read, FileShare.Read))
170+
{
171+
int read = fs.Read(header, 0, header.Length);
172+
if (read < header.Length || !header.SequenceEqual(magicHeader))
173+
{
174+
throw new InvalidDataException("The specified file does not appear to be an OLE Compound File (invalid header).");
175+
}
176+
}
177+
178+
// Open the compound file as an IStorage implementation wrapped by DisposableIStorage.
179+
using DisposableIStorage storage = new(compoundFilePath, STGM.DIRECT | STGM.READ | STGM.SHARE_EXCLUSIVE, IntPtr.Zero);
180+
ExtractStorageToDictionary(storage, string.Empty, result);
181+
182+
return result;
183+
}
184+
185+
private static void ExtractStorageToDictionary(DisposableIStorage storage, string currentPath, Dictionary<string, byte[]> result)
186+
{
187+
IEnumerator<STATSTG> enumerator = storage.EnumElements();
188+
189+
// Enumerate all elements (streams and storages) at the root of the compound file.
190+
while (enumerator.MoveNext())
191+
{
192+
STATSTG entryStat = enumerator.Current;
193+
string entryPath = string.IsNullOrEmpty(currentPath) ? entryStat.pwcsName : Path.Combine(currentPath, entryStat.pwcsName);
194+
195+
// STGTY_STREAM indicates the element is a stream (treat as a file).
196+
if (entryStat.type == (int)STGTY.STGTY_STREAM)
197+
{
198+
// Open the stream for reading from the compound file.
199+
using DisposableIStream stream = storage.OpenStream(entryStat.pwcsName, IntPtr.Zero, STGM.READ | STGM.SHARE_EXCLUSIVE);
200+
201+
// Query stream statistics to determine its size.
202+
STATSTG streamStat = stream.Stat((int)STATFLAG.STATFLAG_DEFAULT);
203+
204+
// Allocate a buffer exactly the size of the stream and read it in one call.
205+
byte[] buffer = new byte[streamStat.cbSize];
206+
stream.Read(buffer, buffer.Length);
207+
208+
result[entryPath] = buffer;
209+
}
210+
// STGTY_STORAGE indicates the element is a nested storage (treat as a directory).
211+
else if (entryStat.type == (int)STGTY.STGTY_STORAGE)
212+
{
213+
using DisposableIStorage subStorage = storage.OpenStorage(entryStat.pwcsName, null, STGM.READ | STGM.SHARE_EXCLUSIVE, IntPtr.Zero);
214+
ExtractStorageToDictionary(subStorage, entryPath, result);
215+
}
216+
}
217+
}
218+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Copyright © 2017-2025 QL-Win Contributors
2+
//
3+
// This file is part of QuickLook program.
4+
//
5+
// This program is free software: you can redistribute it and/or modify
6+
// it under the terms of the GNU General Public License as published by
7+
// the Free Software Foundation, either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// This program is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU General Public License
16+
// along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
18+
using System.Collections.Generic;
19+
using System.IO;
20+
using System.Linq;
21+
22+
namespace QuickLook.Plugin.ArchiveViewer.CompoundFileBinary;
23+
24+
/// <summary>
25+
/// Utility to extract contents from an EIF archive and optionally reorder images
26+
/// based on metadata stored in Face.dat. The EIF format is a Compound File Binary
27+
/// (structured storage) used by QQ for emoji packs.
28+
/// </summary>
29+
public static class EifExtractor
30+
{
31+
/// <summary>
32+
/// File name of the Face.dat metadata stream inside EIF archives.
33+
/// Face.dat contains mapping information used to order and rename images.
34+
/// </summary>
35+
public const string FaceDat = "Face.dat";
36+
37+
/// <summary>
38+
/// Extracts files from the compound file at <paramref name="path"/> into
39+
/// <paramref name="outputDirectory"/>. If Face.dat exists inside the archive,
40+
/// images will be renamed and reordered according to the mapping in Face.dat.
41+
/// </summary>
42+
/// <param name="path">Path to the EIF compound file.</param>
43+
/// <param name="outputDirectory">Destination directory to write extracted files.</param>
44+
public static void ExtractToDirectory(string path, string outputDirectory)
45+
{
46+
// Extract all streams from the compound file into an in-memory dictionary
47+
Dictionary<string, byte[]> compoundFile = CompoundFileExtractor.ExtractToDictionary(path);
48+
49+
// If Face.dat exists, build mapping and reorder images accordingly
50+
if (compoundFile.ContainsKey(FaceDat))
51+
{
52+
// Build group -> (filename -> index) mapping from Face.dat
53+
Dictionary<string, Dictionary<string, int>> faceDat = FaceDatDecoder.Decode(compoundFile[FaceDat]);
54+
55+
// Flatten mapping to key '\\' joined: "group\filename" -> index
56+
Dictionary<string, int> faceDatMapper = faceDat.SelectMany(
57+
outer => outer.Value,
58+
(outer, inner) => new { Key = $@"{outer.Key}\{inner.Key}", inner.Value })
59+
.ToDictionary(x => x.Key, x => x.Value);
60+
61+
// Prepare output dictionary for files that match mapping
62+
Dictionary<string, byte[]> output = [];
63+
64+
foreach (var kv in faceDatMapper)
65+
{
66+
if (compoundFile.ContainsKey(kv.Key))
67+
{
68+
// Create a new key using the index as file name and keep original extension
69+
string newKey = Path.Combine(Path.GetDirectoryName(kv.Key),
70+
faceDatMapper[kv.Key] + Path.GetExtension(kv.Key));
71+
72+
output[newKey] = compoundFile[kv.Key];
73+
}
74+
}
75+
76+
// Ensure target directory exists
77+
Directory.CreateDirectory(outputDirectory);
78+
79+
// Write each matched file to disk using its new name
80+
foreach (var kv in output)
81+
{
82+
(string relativePath, byte[] data) = (kv.Key, kv.Value);
83+
string fullPath = Path.Combine(outputDirectory, relativePath);
84+
85+
// Ensure parent directory exists
86+
string dir = Path.GetDirectoryName(fullPath);
87+
if (!string.IsNullOrEmpty(dir))
88+
Directory.CreateDirectory(dir);
89+
90+
// Write file bytes
91+
File.WriteAllBytes(fullPath, data);
92+
}
93+
}
94+
}
95+
}

QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/CompoundFileBinary/FaceDatDecoder.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ namespace QuickLook.Plugin.ArchiveViewer.CompoundFileBinary;
2929
/// - Locates a repeating-key pattern and extracts the XOR-encrypted block
3030
/// - XOR-decodes the block and parses group\filename entries
3131
/// Provides a method to build the same group -> (filename -> index) mapping as the Python tool.
32+
/// Reference: https://github.com/readme9txt/QQEIF-Extractor
3233
/// </summary>
3334
public static class FaceDatDecoder
3435
{

QuickLook.Plugin/QuickLook.Plugin.ArchiveViewer/Plugin.MoreMenu.cs

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,24 +25,38 @@
2525
using System.IO;
2626
using System.Reflection;
2727
using System.Threading.Tasks;
28+
using System.Windows;
2829
using System.Windows.Input;
2930
using WindowsAPICodePack.Dialogs;
3031

3132
namespace QuickLook.Plugin.ArchiveViewer;
3233

3334
public partial class Plugin
3435
{
36+
/// <summary>
37+
/// Command to extract archive contents to a directory. Executed asynchronously.
38+
/// </summary>
3539
public ICommand ExtractToDirectoryCommand { get; }
3640

41+
/// <summary>
42+
/// Constructor - initializes commands used by the plugin.
43+
/// </summary>
3744
public Plugin()
3845
{
3946
ExtractToDirectoryCommand = new AsyncRelayCommand(ExtractToDirectoryAsync);
4047
}
4148

49+
/// <summary>
50+
/// Return additional "More" menu items for the plugin.
51+
/// When the current file is an EIF archive, a menu item to extract to directory is provided.
52+
/// </summary>
4253
public IEnumerable<IMenuItem> GetMenuItems()
4354
{
44-
if (_path.EndsWith(".eif", StringComparison.OrdinalIgnoreCase))
55+
// Currently only supports for CFB and EIF files
56+
if (_path.EndsWith(".cfb", StringComparison.OrdinalIgnoreCase)
57+
|| _path.EndsWith(".eif", StringComparison.OrdinalIgnoreCase))
4558
{
59+
// Use external Translations.config shipped next to the executing assembly
4660
string translationFile = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Translations.config");
4761

4862
yield return new MoreMenuItem()
@@ -55,6 +69,10 @@ public IEnumerable<IMenuItem> GetMenuItems()
5569
}
5670
}
5771

72+
/// <summary>
73+
/// Show folder picker and extract archive contents to the chosen directory.
74+
/// For EIF files, prompt the user whether to apply EIF-specific Face.dat ordering.
75+
/// </summary>
5876
public async Task ExtractToDirectoryAsync()
5977
{
6078
using CommonOpenFileDialog dialog = new()
@@ -64,25 +82,39 @@ public async Task ExtractToDirectoryAsync()
6482

6583
if (dialog.ShowDialog() == CommonFileDialogResult.Ok)
6684
{
67-
await Task.Run(() =>
85+
if (_path.EndsWith(".cfb", StringComparison.OrdinalIgnoreCase))
6886
{
69-
if (_path.EndsWith(".cfb", StringComparison.OrdinalIgnoreCase))
70-
{
71-
CompoundFileExtractor.ExtractToDirectory(_path, dialog.FileName);
72-
}
73-
else if (_path.EndsWith(".eif", StringComparison.OrdinalIgnoreCase))
87+
// Generic compound file extraction
88+
await Task.Run(() =>
7489
{
7590
CompoundFileExtractor.ExtractToDirectory(_path, dialog.FileName);
91+
});
92+
}
93+
else if (_path.EndsWith(".eif", StringComparison.OrdinalIgnoreCase))
94+
{
95+
string translationFile = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Translations.config");
7696

77-
string faceDatPath = Path.Combine(dialog.FileName, "face.dat");
97+
// Ask the user whether to apply EIF-specific `Face.dat` ordering during extraction
98+
MessageBoxResult result = MessageBox.Show(TranslationHelper.Get("MW_ExtractToDirectory_EIFOrderFaceDat",
99+
translationFile), "QuickLook", MessageBoxButton.YesNo, MessageBoxImage.Question);
78100

79-
if (File.Exists(faceDatPath))
101+
// If user chooses Yes, use EifExtractor which reorders images according to `Face.dat`
102+
if (result == MessageBoxResult.Yes)
103+
{
104+
await Task.Run(() =>
105+
{
106+
EifExtractor.ExtractToDirectory(_path, dialog.FileName);
107+
});
108+
}
109+
else
110+
{
111+
// Fallback: generic compound file extraction
112+
await Task.Run(() =>
80113
{
81-
Dictionary<string, Dictionary<string, int>> faceDat = FaceDatDecoder.Decode(File.ReadAllBytes(faceDatPath));
82-
_ = faceDat;
83-
}
114+
CompoundFileExtractor.ExtractToDirectory(_path, dialog.FileName);
115+
});
84116
}
85-
});
117+
}
86118
}
87119
}
88120
}

0 commit comments

Comments
 (0)