icoextract 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
icoextract/__init__.py ADDED
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: MIT
3
+ # Copyright (c) 2015-2016 Fadhil Mandaga
4
+ # Copyright (c) 2019-2026 James Lu <james@overdrivenetworks.com>
5
+ """
6
+ Windows executable icon extractor.
7
+
8
+ .. include:: ../LIB-USAGE.md
9
+ """
10
+
11
+ from contextlib import ExitStack
12
+ import enum
13
+ import logging
14
+ import mmap
15
+
16
+ from .base_extractor import BaseIconExtractor
17
+ from .exceptions import (
18
+ IconExtractorError,
19
+ IconNotFoundError,
20
+ NoIconsAvailableError,
21
+ InvalidIconDefinitionError,
22
+ UnknownExecutableError,
23
+ )
24
+ from .ne_extractor import NEIconExtractor
25
+ from .pe_extractor import PEIconExtractor
26
+
27
+ logger = logging.getLogger("icoextract")
28
+ logging.basicConfig()
29
+
30
+ try:
31
+ from .version import __version__
32
+ except ImportError:
33
+ __version__ = 'unknown'
34
+ logger.info('icoextract: failed to read program version')
35
+
36
+ __all__ = [
37
+ 'IconExtractor',
38
+ 'IconExtractorError',
39
+ 'IconNotFoundError',
40
+ 'NoIconsAvailableError',
41
+ 'InvalidIconDefinitionError',
42
+ 'UnknownExecutableError',
43
+ 'ExecutableType',
44
+ ]
45
+
46
+ class ExecutableType(enum.Enum):
47
+ """Enum for supported executable formats."""
48
+ AUTO = 0
49
+ PE = 1
50
+ NE = 2
51
+
52
+ def detect_executable_type(buf: bytearray | mmap.mmap) -> ExecutableType:
53
+ if buf[:2] != b'MZ':
54
+ raise UnknownExecutableError("Unknown executable type (no MZ header)")
55
+
56
+ # Get the offset to the real header (located at 0x3C)
57
+ e_lfanew = int.from_bytes(buf[0x3C:0x40], byteorder='little')
58
+ signature = buf[e_lfanew:e_lfanew+4]
59
+
60
+ if signature.startswith(b'PE\x00\x00'):
61
+ return ExecutableType.PE
62
+ if signature.startswith(b'NE'):
63
+ return ExecutableType.NE
64
+ raise UnknownExecutableError("Unknown / unsupported executable type")
65
+
66
+ def IconExtractor(filename: str | None = None,
67
+ data: bytearray | mmap.mmap | None = None,
68
+ exe_type: ExecutableType = ExecutableType.AUTO) -> BaseIconExtractor:
69
+ """
70
+ IconExtractor factory function.
71
+
72
+ When `exe_type` is set to *AUTO*, this will return an `icoextract.pe_extractor.PEIconExtractor`
73
+ for Win32/Win64 PE executables and an `icoextract.ne_extractor.NEIconExtractor` for Win16 NE
74
+ executables.
75
+ """
76
+ if filename is None and data is None:
77
+ raise ValueError("filename and data cannot both be None")
78
+
79
+ if exe_type == ExecutableType.AUTO:
80
+ if data is not None:
81
+ exe_type = detect_executable_type(data)
82
+ else:
83
+ assert filename
84
+ with ExitStack() as stack:
85
+ f = stack.enter_context(open(filename, 'rb'))
86
+ mm = stack.enter_context(mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ))
87
+ exe_type = detect_executable_type(mm)
88
+ logger.debug("Detected executable type as %s", exe_type)
89
+
90
+ if exe_type == ExecutableType.PE:
91
+ return PEIconExtractor(filename, data)
92
+ if exe_type == ExecutableType.NE:
93
+ return NEIconExtractor(filename, data)
94
+
95
+ raise UnknownExecutableError("Unknown executable type")
96
+
97
+ __pdoc__ = {
98
+ 'scripts': False,
99
+ 'version': False,
100
+ }
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: MIT
3
+ # Copyright (c) 2019-2026 James Lu <james@overdrivenetworks.com>
4
+
5
+ import abc
6
+ import io
7
+ import struct
8
+
9
+ from .types import ExtractedGroupIcon, GroupIconWithIconOffsets, ResourceID
10
+
11
+ class BaseIconExtractor(abc.ABC):
12
+ """Base icon extractor class containing .ico writing logic."""
13
+
14
+ @abc.abstractmethod
15
+ def _extract_icon(self, index: int = 0, resource_id: int | str | None = None) -> ExtractedGroupIcon:
16
+ """Extract an icon by its index or resource ID."""
17
+
18
+ @abc.abstractmethod
19
+ def list_group_icons(self) -> list[tuple[ResourceID, GroupIconWithIconOffsets]]:
20
+ """
21
+ Returns all group icon entries as a list of (resource ID, group icon dir entry) tuples.
22
+ """
23
+
24
+ def _write_ico(self, fd, icons: ExtractedGroupIcon):
25
+ """Writes ICO data to a file descriptor."""
26
+ fd.write(b"\x00\x00") # 2 reserved bytes
27
+ fd.write(struct.pack("<H", 1)) # 0x1 (little endian) specifying that this is an .ICO image
28
+ fd.write(struct.pack("<H", len(icons))) # number of images
29
+
30
+ dataoffset = 6 + (len(icons) * 16)
31
+ # First pass: write the icon dir entries
32
+ for datapair in icons:
33
+ group_icon, icon_data = datapair
34
+ # Elements in ICONDIRENTRY and GRPICONDIRENTRY are all the same
35
+ # except the last value, which is a 2 byte ID in GRPICONDIRENTRY and
36
+ # the 4 byte offset from the beginning of the file in ICONDIRENTRY.
37
+ fd.write(bytes(group_icon)[:12])
38
+ fd.write(struct.pack("<I", dataoffset))
39
+ dataoffset += len(icon_data) # Increase offset for next image
40
+
41
+ # Second pass: write the icon data
42
+ for datapair in icons:
43
+ group_icon, icon_data = datapair
44
+ fd.write(icon_data)
45
+
46
+ def export_icon(self, filename: str, num: int = 0, resource_id: int | str | None = None) -> None:
47
+ """
48
+ Exports ICO data for the requested group icon to `filename`.
49
+
50
+ Icons can be selected by index (`num`) or resource ID. By default, the first icon in the binary is exported.
51
+ """
52
+ group_icon = self._extract_icon(index=num, resource_id=resource_id)
53
+ with open(filename, 'wb') as f:
54
+ self._write_ico(f, group_icon)
55
+
56
+ def get_icon(self, num: int = 0, resource_id: int | str | None = None) -> io.BytesIO:
57
+ """
58
+ Exports ICO data for the requested group icon as a `io.BytesIO` instance.
59
+
60
+ Icons can be selected by index (`num`) or resource ID. By default, the first icon in the binary is exported.
61
+ """
62
+ group_icon = self._extract_icon(index=num, resource_id=resource_id)
63
+ f = io.BytesIO()
64
+ self._write_ico(f, group_icon)
65
+ return f
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: MIT
3
+ # Copyright (c) 2019-2026 James Lu <james@overdrivenetworks.com>
4
+
5
+ class IconExtractorError(Exception):
6
+ """Superclass for exceptions raised by IconExtractor."""
7
+
8
+ class IconNotFoundError(IconExtractorError):
9
+ """Exception raised when extracting an icon index or resource ID that does not exist."""
10
+
11
+ class NoIconsAvailableError(IconExtractorError):
12
+ """Exception raised when the input program has no icon resources."""
13
+
14
+ class InvalidIconDefinitionError(IconExtractorError):
15
+ """Exception raised when the input program has an invalid icon resource."""
16
+
17
+ class UnknownExecutableError(IconExtractorError):
18
+ """Exception raised when the executable type is invalid or not supported."""
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: MIT
3
+ # Copyright (c) 2019-2026 James Lu <james@overdrivenetworks.com>
4
+
5
+ import logging
6
+
7
+ try:
8
+ import nefile
9
+ except ImportError:
10
+ nefile = None
11
+
12
+ from .base_extractor import BaseIconExtractor
13
+ from .exceptions import (
14
+ IconNotFoundError,
15
+ NoIconsAvailableError
16
+ )
17
+ from .types import (
18
+ ExtractedGroupIcon,
19
+ GroupIconDirEntry,
20
+ GroupIconWithIconOffsets,
21
+ ResourceID,
22
+ )
23
+
24
+ logger = logging.getLogger("icoextract")
25
+
26
+ class NEIconExtractor(BaseIconExtractor):
27
+ """Win16 New Executable (NE) icon extractor."""
28
+
29
+ def __init__(self, filename=None, data=None):
30
+ """
31
+ Loads an Win16 New Executable from the given `filename` or `data` (raw buffer).
32
+ If both `filename` and `data` are given, `filename` takes precedence.
33
+
34
+ If the executable has contains no icons, this will raise `NoIconsAvailableError`.
35
+ """
36
+ if nefile is None:
37
+ raise ImportError("nefile module must be installed to extract NE programs")
38
+ self._ne = nefile.NE(filename, data)
39
+
40
+ if not self._ne.resource_table:
41
+ raise NoIconsAvailableError("Executable does not have any resources")
42
+ if not (group_icon_resources := self._ne.resource_table.resources.get(
43
+ nefile.resource_table.ResourceType.RT_GROUP_ICON)):
44
+ raise NoIconsAvailableError("Executable does not have any group icons")
45
+
46
+ self._group_icon_resources = group_icon_resources
47
+
48
+ @staticmethod
49
+ def _convert_nefile_icon_dir_entry(nefile_icon_dir_entry) -> GroupIconDirEntry:
50
+ assert nefile is not None
51
+ assert isinstance(nefile_icon_dir_entry, nefile.resources.icon.IconDirectoryEntry)
52
+ return GroupIconDirEntry(
53
+ Width=nefile_icon_dir_entry.width,
54
+ Height=nefile_icon_dir_entry.height,
55
+ ColorCount=nefile_icon_dir_entry.total_palette_colors,
56
+ Planes=nefile_icon_dir_entry.color_planes,
57
+ BitCount=nefile_icon_dir_entry.bits_per_pixel,
58
+ BytesInRes=nefile_icon_dir_entry.icon_size_in_bytes,
59
+ ID=nefile_icon_dir_entry.icon_resource_id,
60
+ )
61
+
62
+ def list_group_icons(self) -> list[tuple[ResourceID, GroupIconWithIconOffsets]]:
63
+ assert nefile is not None
64
+ result = []
65
+ resources_offset = self._ne.resource_table.resource_table_start_offset
66
+ icon_rtt = self._ne.resource_table.resource_type_tables[
67
+ nefile.resource_table.ResourceType.RT_ICON
68
+ ]
69
+ icon_resource_declarations = {
70
+ rdecl.id: rdecl for rdecl in icon_rtt.resource_declarations
71
+ }
72
+ for resource_id, nefile_group_icon in self._group_icon_resources.items():
73
+ if isinstance(resource_id, str):
74
+ resource_id_wrapper = ResourceID(raw_id=None, name=resource_id)
75
+ else:
76
+ resource_id_wrapper = ResourceID(resource_id)
77
+
78
+ icon_infos = []
79
+ for nefile_icon_dir_entry in nefile_group_icon.icon_directory.directory_entries:
80
+ grp_icon_dir_entry = self._convert_nefile_icon_dir_entry(nefile_icon_dir_entry)
81
+ icon_rdecl = icon_resource_declarations[grp_icon_dir_entry.ID]
82
+ offset = icon_rdecl.data_start_offset
83
+ file_offset = offset + resources_offset
84
+ icon_infos.append((grp_icon_dir_entry, offset, file_offset))
85
+ result.append((resource_id_wrapper, icon_infos))
86
+ return result
87
+
88
+ def _extract_icon(self, index: int = 0, resource_id: int | str | None = None) -> ExtractedGroupIcon:
89
+ if resource_id is not None:
90
+ try:
91
+ nefile_group_icon = self._group_icon_resources[resource_id]
92
+ except KeyError:
93
+ raise IconNotFoundError(f"No icon exists with resource ID {resource_id!r}") from None
94
+ else:
95
+ icons_by_index = list(self._group_icon_resources.values())
96
+ try:
97
+ nefile_group_icon = icons_by_index[index]
98
+ except IndexError:
99
+ raise IconNotFoundError(f"No icon exists at index {index}") from None
100
+
101
+ results = []
102
+ for nefile_icon_dir_entry in nefile_group_icon.icon_directory.directory_entries:
103
+ resource_id = nefile_icon_dir_entry.icon_resource_id
104
+ grp_icon_dir_entry = self._convert_nefile_icon_dir_entry(nefile_icon_dir_entry)
105
+ icondata = nefile_group_icon.icons[resource_id].data
106
+ results.append((grp_icon_dir_entry, icondata))
107
+ return results
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: MIT
3
+ # Copyright (c) 2015-2016 Fadhil Mandaga
4
+ # Copyright (c) 2019-2026 James Lu <james@overdrivenetworks.com>
5
+
6
+ import ctypes
7
+ import logging
8
+ import os
9
+ import pathlib
10
+ import platform
11
+
12
+ import pefile
13
+
14
+ from .base_extractor import BaseIconExtractor
15
+ from .exceptions import (
16
+ IconNotFoundError,
17
+ InvalidIconDefinitionError,
18
+ NoIconsAvailableError
19
+ )
20
+ from .types import (
21
+ ExtractedGroupIcon,
22
+ GroupIconDir,
23
+ GroupIconDirEntry,
24
+ GroupIconWithIconOffsets,
25
+ ResourceID,
26
+ )
27
+
28
+ logger = logging.getLogger("icoextract")
29
+
30
+ class PEIconExtractor(BaseIconExtractor):
31
+ """Win32/Win64 Portable Executable (PE) icon extractor."""
32
+
33
+ def __init__(self, filename=None, data=None):
34
+ """
35
+ Loads an Win32/Win64 Portable Executable from the given `filename` or `data` (raw buffer).
36
+ If both `filename` and `data` are given, `filename` takes precedence.
37
+
38
+ If the executable has contains no icons, this will raise `NoIconsAvailableError`.
39
+ """
40
+ # Use fast loading and explicitly load the RESOURCE directory entry. This saves a LOT of time
41
+ # on larger files
42
+ self._pe = pefile.PE(name=filename, data=data, fast_load=True)
43
+ self._pe.parse_data_directories(pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_RESOURCE'])
44
+
45
+ if not hasattr(self._pe, 'DIRECTORY_ENTRY_RESOURCE'):
46
+ raise NoIconsAvailableError("File has no resources")
47
+
48
+ # Reverse the list of entries before making the mapping so that earlier values take precedence
49
+ # When an executable includes multiple icon resources, we should use only the first one.
50
+ # pylint: disable=no-member
51
+ resources = {rsrc.id: rsrc for rsrc in reversed(self._pe.DIRECTORY_ENTRY_RESOURCE.entries)}
52
+
53
+ self._groupiconres = resources.get(pefile.RESOURCE_TYPE["RT_GROUP_ICON"])
54
+ if not self._groupiconres:
55
+ if filename and platform.system() == "Windows":
56
+ self._print_windows_usage_hint(filename)
57
+ raise NoIconsAvailableError("File has no group icon resources")
58
+ self._rticonres = resources.get(pefile.RESOURCE_TYPE["RT_ICON"])
59
+
60
+ self._group_icons = self._groupiconres.directory.entries
61
+ self._group_icons_by_id: dict[int | str, pefile.ResourceDirEntryData] = {}
62
+ for entry in self._group_icons:
63
+ self._group_icons_by_id[entry.struct.Name] = entry
64
+ # For resources with a string name, track them by both the string and the underlying
65
+ # numerical index
66
+ if entry.name:
67
+ self._group_icons_by_id[str(entry.name)] = entry
68
+
69
+ self._icons = {icon_entry_list.id: icon_entry_list.directory.entries[0] # Select first language
70
+ for icon_entry_list in self._rticonres.directory.entries}
71
+
72
+ def list_group_icons(self) -> list[tuple[ResourceID, GroupIconWithIconOffsets]]:
73
+ results = []
74
+ for entry in self._group_icons:
75
+ resource_id = ResourceID(
76
+ raw_id=entry.struct.Name,
77
+ name=str(entry.name) if entry.name else None
78
+ )
79
+ grp_icon_resource = self._group_icons_by_id[entry.struct.Name]
80
+ grp_icon_data_full = self._extract_from_pefile(grp_icon_resource, skip_data=True)
81
+ # Return only the metadata and offsets
82
+ grp_icon_data = [
83
+ (grp_icon_dir_entry, resource_offset, file_offset)
84
+ for (grp_icon_dir_entry, _, resource_offset, file_offset)
85
+ in grp_icon_data_full
86
+ ]
87
+ results.append((resource_id, grp_icon_data))
88
+ return results
89
+
90
+ def _extract_from_pefile(
91
+ self,
92
+ resource_dir_entry_data: pefile.ResourceDirEntryData,
93
+ skip_data: bool = False,
94
+ ) -> list[tuple[GroupIconDirEntry, bytes, int, int]]:
95
+ """
96
+ Returns the specified group icon in the binary.
97
+
98
+ Result is a list of (group icon dir entry, icon data) tuples.
99
+ """
100
+ icon_lang = None
101
+ if resource_dir_entry_data.struct.DataIsDirectory:
102
+ # Select the first language from subfolders as needed.
103
+ resource_dir_entry_data = resource_dir_entry_data.directory.entries[0]
104
+ icon_lang = resource_dir_entry_data.struct.Name
105
+ logger.debug("Picking first language %s", icon_lang)
106
+
107
+ # Read the data pointed to by the group icon directory (GRPICONDIR) struct.
108
+ rva = resource_dir_entry_data.data.struct.OffsetToData
109
+ grp_icon_data = self._pe.get_data(rva, resource_dir_entry_data.data.struct.Size)
110
+
111
+ grp_icon_dir = GroupIconDir.from_buffer_copy(grp_icon_data)
112
+ logger.debug("Group icon has %d images: %s",
113
+ grp_icon_dir.Count, grp_icon_dir)
114
+
115
+ # pylint: disable=no-member
116
+ if grp_icon_dir.Reserved:
117
+ # pylint: disable=no-member
118
+ raise InvalidIconDefinitionError("Invalid group icon definition (got Reserved=%s instead of 0)"
119
+ % hex(grp_icon_dir.Reserved))
120
+
121
+ # For each group icon entry (GRPICONDIRENTRY) that immediately follows, read the struct and look up the
122
+ # corresponding icon image
123
+ extracted_grp_icons = []
124
+ icon_offset = ctypes.sizeof(grp_icon_dir)
125
+ for grp_icon_index in range(grp_icon_dir.Count):
126
+ grp_icon_dir_entry = GroupIconDirEntry.from_buffer_copy(grp_icon_data, icon_offset)
127
+ icon_offset += ctypes.sizeof(grp_icon_dir_entry)
128
+ logger.debug("Got group icon entry %d: %s", grp_icon_index, grp_icon_dir_entry)
129
+
130
+ icon_entry = self._icons[grp_icon_dir_entry.ID]
131
+ logger.debug("Got icon data for ID %d: %s", grp_icon_dir_entry.ID, icon_entry.data.struct)
132
+
133
+ icon_res_offset = icon_entry.data.struct.OffsetToData
134
+ icon_file_offset = self._pe.get_offset_from_rva(icon_res_offset)
135
+ icon_data = b''
136
+ if not skip_data:
137
+ icon_data = self._pe.get_data(icon_res_offset, icon_entry.data.struct.Size)
138
+ extracted_grp_icons.append((grp_icon_dir_entry, icon_data, icon_res_offset, icon_file_offset))
139
+ return extracted_grp_icons
140
+
141
+ def _extract_icon(self, index: int = 0, resource_id: int | str | None = None) -> ExtractedGroupIcon:
142
+ if resource_id is not None:
143
+ try:
144
+ resource_dir_entry_data = self._group_icons_by_id[resource_id]
145
+ except KeyError:
146
+ raise IconNotFoundError(f"No icon exists with resource ID {resource_id!r}") from None
147
+ else:
148
+ try:
149
+ resource_dir_entry_data = self._group_icons[index]
150
+ except IndexError:
151
+ raise IconNotFoundError(f"No icon exists at index {index}") from None
152
+
153
+ # Return only the GroupIconDirEntry metadata and icon bytes
154
+ return [
155
+ (grp_icon_dir_entry, icon_data)
156
+ for (grp_icon_dir_entry, icon_data, _, _)
157
+ in self._extract_from_pefile(resource_dir_entry_data)
158
+ ]
159
+
160
+ @staticmethod
161
+ def _print_windows_usage_hint(filename):
162
+ path = pathlib.Path(filename)
163
+ systemroot = pathlib.Path(os.getenv('SYSTEMROOT'))
164
+ if path.is_relative_to(systemroot / 'System32') or \
165
+ path.is_relative_to(systemroot / 'SysWOW64'):
166
+ mun_path = pathlib.Path(systemroot / 'SystemResources' / (path.name + '.mun'))
167
+ if mun_path.is_file():
168
+ logger.warning(
169
+ 'System DLL files in Windows 10 1903+ no longer contain icons. '
170
+ 'Try extracting from %s instead.', mun_path)
@@ -0,0 +1 @@
1
+ # Stub so that scripts/ is treated as a package
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: MIT
3
+ # Copyright (c) 2019-2026 James Lu <james@overdrivenetworks.com>
4
+ """
5
+ Windows executable icon extractor.
6
+ """
7
+
8
+ import argparse
9
+ import logging
10
+ import os.path
11
+
12
+ from icoextract import IconExtractor, logger, __version__
13
+
14
+ _WRONG_EXTENSIONS_HINT = {'.jpg', '.jpeg', '.png'}
15
+ def main():
16
+ parser = argparse.ArgumentParser(description=__doc__)
17
+ parser.add_argument("-V", "--version", action='version', version=f'icoextract {__version__}')
18
+ parser.add_argument("-n", "--num", type=int, help="index of icon to extract", default=0)
19
+ parser.add_argument("-i", "--id", type=str, help="resource ID of icon to extract", default=None)
20
+ parser.add_argument("-v", "--verbose", action="store_true", help="enables debug logging")
21
+ parser.add_argument("input", help="input filename (.exe/.dll/.mun)")
22
+ parser.add_argument("output", help="output filename (.ico)")
23
+ args = parser.parse_args()
24
+
25
+ if args.verbose:
26
+ logger.setLevel(logging.DEBUG)
27
+
28
+ extractor = IconExtractor(args.input)
29
+
30
+ try:
31
+ resource_id = int(args.id)
32
+ except (ValueError, TypeError):
33
+ resource_id = args.id
34
+
35
+ extractor.export_icon(args.output, num=args.num, resource_id=resource_id)
36
+ file_ext = os.path.splitext(args.output)[1].lower()
37
+ if file_ext in _WRONG_EXTENSIONS_HINT:
38
+ logger.warning('This tool outputs .ico files, not %s. The resulting file will have the wrong file extension.',
39
+ file_ext)
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: MIT
3
+ # Copyright (c) 2019-2026 James Lu <james@overdrivenetworks.com>
4
+ """
5
+ Lists group icons present in a program.
6
+ """
7
+
8
+ import argparse
9
+ import logging
10
+
11
+ from icoextract import IconExtractor, logger, __version__
12
+
13
+ def main():
14
+ parser = argparse.ArgumentParser(description=__doc__)
15
+ parser.add_argument("-V", "--version", action='version', version=f'icoextract {__version__}')
16
+ parser.add_argument("-v", "--verbose", action="store_true", help="enables debug logging")
17
+ parser.add_argument("input", help="input filename")
18
+ args = parser.parse_args()
19
+
20
+ if args.verbose:
21
+ logger.setLevel(logging.DEBUG)
22
+
23
+ extractor = IconExtractor(args.input)
24
+ for idx, entry in enumerate(extractor.list_group_icons()):
25
+ resource_id, grp_icon_dir_entries = entry
26
+ print(f"Group Icon Index: {idx} ", end='')
27
+
28
+ print(f"ID: {resource_id}", end='')
29
+ if resource_id.raw_id:
30
+ print(f"({hex(resource_id.raw_id)})", end='')
31
+ print(" ", end='')
32
+
33
+ print(f"Count: {len(grp_icon_dir_entries)}")
34
+ for (grp_icon_dir_entry, resource_offset, file_offset) in grp_icon_dir_entries:
35
+ print(f" Icon ID: {grp_icon_dir_entry.ID}"
36
+ # Width and Height are u8 values where 0 means 256
37
+ f" Width: {grp_icon_dir_entry.Width or 256}"
38
+ f" Height: {grp_icon_dir_entry.Height or 256}"
39
+ f" Resource Offset: {hex(resource_offset)}"
40
+ f" File Offset: {hex(file_offset)}"
41
+ f" Size: {grp_icon_dir_entry.BytesInRes}")
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: MIT
3
+ # Copyright (c) 2019-2026 James Lu <james@overdrivenetworks.com>
4
+ """
5
+ Linux (freedesktop.org) thumbnailer for Windows PE files (.exe/.dll)
6
+ """
7
+
8
+ import argparse
9
+ import logging
10
+ import sys
11
+
12
+ from PIL import Image
13
+
14
+ from icoextract import IconExtractor, IconExtractorError, logger, __version__
15
+
16
+ def generate_thumbnail(inputfile, outfile, size=256, force_resize=False):
17
+ """
18
+ Generates a thumbnail for an .exe file.
19
+
20
+ inputfile: the input file path (%i)
21
+ outfile: output filename (%o)
22
+ size: determines the thumbnail output size (%s)
23
+ """
24
+ try:
25
+ extractor = IconExtractor(inputfile)
26
+ except RuntimeError:
27
+ logger.debug("Failed to extract icon for %s:", inputfile, exc_info=True)
28
+ sys.exit(1)
29
+ data = extractor.get_icon()
30
+
31
+ im = Image.open(data) # Open up the .ico from memory
32
+ if force_resize:
33
+ logger.debug("Force resizing icon to %dx%d", size, size)
34
+ im = im.resize((size, size))
35
+ else:
36
+ if size > 256:
37
+ logger.warning('Icon sizes over 256x256 are not supported')
38
+ size = 256
39
+ elif size not in (128, 256):
40
+ logger.warning('Unsupported size %d, falling back to 128x128', size)
41
+ size = 128
42
+
43
+ if size == 128:
44
+ # If large size thumbnail wasn't requested but one is available, pick an 128x128 icon if available;
45
+ # otherwise scale down from 256x256 to 128x128. 128x128 is the largest resolution allowed for
46
+ # "normal" size thumbnails.
47
+ if (128, 128) in im.info['sizes']:
48
+ logger.debug("Using native 128x128 icon")
49
+ im.size = (128, 128)
50
+ elif im.size > (128, 128):
51
+ logger.debug("Downsizing icon to 128x128")
52
+ im = im.resize((128, 128))
53
+
54
+ # For legacy apps, try to export the icon with the highest size & bit depth
55
+ # Some programs (e.g. Internet Explorer 6) put a 16 color icon earlier in the file
56
+ if im.size <= (48, 48):
57
+ ico_frames = list(enumerate(im.ico.entry))
58
+ best_frame_idx, ico_header = max(ico_frames, key=lambda pair: (pair[1].square, pair[1].color_depth))
59
+ logger.debug("Legacy app: selecting icon %s", ico_header)
60
+ im = im.ico.frame(best_frame_idx)
61
+
62
+ logger.debug("Writing size %s thumbnail for %s to %s", size, inputfile, outfile)
63
+ im.save(outfile, "PNG")
64
+
65
+ def main():
66
+ parser = argparse.ArgumentParser(description=__doc__)
67
+ parser.add_argument("-V", "--version", action='version', version=f'exe-thumbnailer, part of icoextract {__version__}')
68
+ parser.add_argument("-s", "--size", type=int, help="size of desired thumbnail", default=256)
69
+ parser.add_argument("-v", "--verbose", action="store_true", help="enables debug logging")
70
+ parser.add_argument("-f", "--force-resize", action="store_true", help="force resize thumbnail to the specified size")
71
+ parser.add_argument("inputfile", help="input file name (.exe/.dll/.mun)")
72
+ parser.add_argument("outfile", help="output file name (.png)")
73
+ args = parser.parse_args()
74
+
75
+ if args.verbose:
76
+ logger.setLevel(logging.DEBUG)
77
+
78
+ try:
79
+ generate_thumbnail(args.inputfile, args.outfile, size=args.size, force_resize=args.force_resize)
80
+ except IconExtractorError as e:
81
+ logger.error("Failed to thumbnail %s: %s", args.inputfile, e)
82
+ logger.debug("Backtrace:", exc_info=True)
icoextract/types.py ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: MIT
3
+ # Copyright (c) 2019-2026 James Lu <james@overdrivenetworks.com>
4
+
5
+ """
6
+ icoextract common types.
7
+
8
+ See https://devblogs.microsoft.com/oldnewthing/20120720-00/?p=7083
9
+ for a reference on ICO structures.
10
+ """
11
+
12
+ import ctypes
13
+
14
+ BYTE = ctypes.c_uint8
15
+ WORD = ctypes.c_uint16
16
+ DWORD = ctypes.c_uint32
17
+
18
+ class ResourceID:
19
+ """Resource ID wrapper.
20
+
21
+ For resources with a string ID, str() will return its string value
22
+ (e.g. "IDI_MAIN_ICON") and int() will return its underlying numeric ID.
23
+
24
+ Numerical icon resource IDs can be accessed via int(), and str() will return
25
+ the number casted to a string.
26
+ """
27
+ def __init__(self, raw_id: int | None = None, name: str | None = None):
28
+ if raw_id is None and name is None:
29
+ raise ValueError("raw_id and name cannot both be None")
30
+ self.raw_id = raw_id
31
+ self.name = name
32
+
33
+ def __str__(self):
34
+ return self.name or str(self.raw_id)
35
+
36
+ def __int__(self):
37
+ if self.raw_id is not None:
38
+ return self.raw_id
39
+ raise ValueError(f"Resource ID {self.name!r} cannot be converted to an int")
40
+
41
+ def __repr__(self):
42
+ if self.name:
43
+ return f'{self.__class__.__name__}({self.name!r})'
44
+ return f'{self.__class__.__name__}({self.raw_id})'
45
+
46
+ class GroupIconDirEntry(ctypes.LittleEndianStructure):
47
+ """ICO GRPICONDIRENTRY / RESDIR structure"""
48
+ _pack_ = 1 # Ensures tight packing (no padding)
49
+ _fields_ = [
50
+ ("Width", BYTE),
51
+ ("Height", BYTE),
52
+ ("ColorCount", BYTE),
53
+ ("Reserved", BYTE),
54
+ ("Planes", WORD),
55
+ ("BitCount", WORD),
56
+ ("BytesInRes", DWORD),
57
+ ("ID", WORD),
58
+ ]
59
+
60
+ class GroupIconDir(ctypes.LittleEndianStructure):
61
+ """ICO GRPICONDIR / NEWHEADER structure"""
62
+ _pack_ = 1
63
+ _fields_ = [
64
+ ("Reserved", WORD),
65
+ ("Type", WORD),
66
+ ("Count", WORD),
67
+ ]
68
+
69
+ ExtractedGroupIcon = list[tuple[GroupIconDirEntry, bytes]]
70
+ GroupIconWithIconOffsets = list[tuple[GroupIconDirEntry, int, int]]
icoextract/version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = '0.3.0'
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: icoextract
3
+ Version: 0.3.0
4
+ Summary: Windows EXE icon extractor
5
+ Author-email: James Lu <james@overdrivenetworks.com>
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Intended Audience :: End Users/Desktop
10
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Operating System :: POSIX
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: pefile
23
+ Provides-Extra: thumbnailer
24
+ Requires-Dist: Pillow; extra == "thumbnailer"
25
+ Provides-Extra: win16
26
+ Requires-Dist: nefile; extra == "win16"
27
+ Provides-Extra: dev
28
+ Requires-Dist: icoextract[thumbnailer]; extra == "dev"
29
+ Requires-Dist: icoextract[win16]; extra == "dev"
30
+ Requires-Dist: pdoc; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ # icoextract
34
+
35
+ [![Build Status](https://woodpecker.overdrivenetworks.com/api/badges/5/status.svg)](https://woodpecker.overdrivenetworks.com/repos/5)
36
+ [![Packaging status](https://repology.org/badge/tiny-repos/icoextract.svg)](https://repology.org/project/icoextract/versions)
37
+
38
+ **icoextract** is an icon extractor library for Windows executables (.exe/.dll/.mun). It also includes a thumbnailer script (`exe-thumbnailer`) for Linux desktops.
39
+
40
+ This project is inspired by [extract-icon-py](https://github.com/firodj/extract-icon-py), [icoutils](https://www.nongnu.org/icoutils/), and others.
41
+
42
+ icoextract aims to be:
43
+
44
+ - Lightweight
45
+ - Portable (cross-platform)
46
+ - Fast on large files
47
+
48
+ ## Installation
49
+
50
+ ### Dependencies
51
+
52
+ - Python 3.10+
53
+ - [pefile](https://github.com/erocarrera/pefile)
54
+ - [Pillow](https://pillow.readthedocs.io/en/stable/) - optional, for exe-thumbnailer
55
+ - [nefile](https://github.com/npjg/nefile) - optional, for Win16 / NE EXE support
56
+
57
+ ### Installing from source
58
+
59
+ You can install the project via pip: `pip3 install icoextract[thumbnailer]`
60
+
61
+ On Linux, you can activate the thumbnailer by copying [`exe-thumbnailer.thumbnailer`](/exe-thumbnailer.thumbnailer) into the thumbnailers directory:
62
+
63
+ - `/usr/local/share/thumbnailers/` if you installed `icoextract` globally
64
+ - `~/.local/share/thumbnailers` if you installed `icoextract` for your user only
65
+
66
+ The thumbnailer should work with any file manager that implements the [Freedesktop Thumbnails Standard](https://specifications.freedesktop.org/thumbnail-spec/thumbnail-spec-latest.html): this includes Nautilus, Caja, Nemo, Thunar (when Tumbler is installed), and PCManFM. KDE / Dolphin uses a different architecture and is *not* supported here.
67
+
68
+ ### Distribution packages
69
+
70
+ You can install icoextract from any of these distribution repositories:
71
+
72
+ [![Packaging status](https://repology.org/badge/vertical-allrepos/icoextract.svg?columns=5)](https://repology.org/project/icoextract/versions)
73
+
74
+
75
+ ## Usage
76
+
77
+ icoextract ships `icoextract` and `icolist` scripts to extract and list icon resources inside a file.
78
+
79
+ **Note**: recent versions of Windows (Windows 10 1903+) have moved icons from system libraries (`shell32.dll`, etc.) into a new [`C:\Windows\SystemResources`](https://superuser.com/questions/1480268/) folder. icoextract can extract these `.mun` files natively, but the `.dll`s themselves no longer contain icons.
80
+
81
+ For API docs, see https://projects.jlu5.com/icoextract.html
82
+
83
+ ```
84
+ usage: icoextract [-h] [-V] [-n NUM] [-i ID] [-v] input output
85
+
86
+ Windows executable icon extractor.
87
+
88
+ positional arguments:
89
+ input input filename (.exe/.dll/.mun)
90
+ output output filename (.ico)
91
+
92
+ options:
93
+ -h, --help show this help message and exit
94
+ -V, --version show program's version number and exit
95
+ -n, --num NUM index of icon to extract
96
+ -i, --id ID resource ID of icon to extract
97
+ -v, --verbose enables debug logging
98
+ ```
99
+
100
+ ```
101
+ usage: icolist [-h] [-V] [-v] input
102
+
103
+ Lists group icons present in a program.
104
+
105
+ positional arguments:
106
+ input input filename
107
+
108
+ options:
109
+ -h, --help show this help message and exit
110
+ -V, --version show program's version number and exit
111
+ -v, --verbose enables debug logging
112
+ ```
@@ -0,0 +1,17 @@
1
+ icoextract/__init__.py,sha256=z5OWuLNir146vZW21b3a8SOQEL8iyLbvPsrJgB9FW6Q,3042
2
+ icoextract/base_extractor.py,sha256=hbg8gGsIvPIiSc50WINKPb-ilbhcn2lyIeLHOiTZB3w,2754
3
+ icoextract/exceptions.py,sha256=QyQBOXh-gQIMS-lLCWN7IJ5OsECc9X0kir5S6AbBpqc,747
4
+ icoextract/ne_extractor.py,sha256=DjpsD62HKs6vBeiBcQVkH9cJyKXEkbvIXrRulYzarsc,4530
5
+ icoextract/pe_extractor.py,sha256=uqqS2pBz_7P7dDDoW31KsSzFjgMicPugoG5ut5QSoZo,7616
6
+ icoextract/types.py,sha256=L9dSrbUV-kWQYQYyg7N8TowiXNIlsKw_1ZWq3rLfB6Y,2055
7
+ icoextract/version.py,sha256=3wVEs2QD_7OcTlD97cZdCeizd2hUbJJ0GeIO8wQIjrk,22
8
+ icoextract/scripts/__init__.py,sha256=sdmatvCNSK7IH6O60271YMOvBgiQNuGCKHMYzmpbvOE,48
9
+ icoextract/scripts/extract.py,sha256=sdIp9lT397mJwPVZE-xHpskE-Ix7n4zqA6MhfzsGgIY,1490
10
+ icoextract/scripts/icolist.py,sha256=JCPBWF-S-zdym52m_1Ort1Aq6v1lwmEOBM9MBJFtRAQ,1632
11
+ icoextract/scripts/thumbnailer.py,sha256=YPELdFojgctUk1P0CV9n0iCM0fVOyhPE2G5VJzC0gRU,3425
12
+ icoextract-0.3.0.dist-info/licenses/LICENSE,sha256=fEExFMN-Gl3DjZptI7fHEbCD8_J1ONExzTRyqyKWrkk,1149
13
+ icoextract-0.3.0.dist-info/METADATA,sha256=2zoUdXiAR4LEFvv08I_V2FY7gd134gdT7u2s0xDFj08,4394
14
+ icoextract-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
+ icoextract-0.3.0.dist-info/entry_points.txt,sha256=7D4Ml66GpVxArdjGH8FkiU6vGRuFWfcyEBnSUCEDNHo,173
16
+ icoextract-0.3.0.dist-info/top_level.txt,sha256=vDrv1SnIBDoCSBkNAzC6EJdwNhNhWTwBXP2kMC9o3rQ,11
17
+ icoextract-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ exe-thumbnailer = icoextract.scripts.thumbnailer:main [thumbnailer]
3
+ icoextract = icoextract.scripts.extract:main
4
+ icolist = icoextract.scripts.icolist:main
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015-2016 Fadhil Mandaga
4
+ Copyright (c) 2019-2026 James Lu <james@overdrivenetworks.com>
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ icoextract