Fixing some gui icons
This commit is contained in:
parent
f1a7bb93e6
commit
a2f3eb084b
8 changed files with 309 additions and 15 deletions
|
|
@ -189,13 +189,13 @@ class ConfigTab(QWidget):
|
||||||
self.nav_buttons.append(btn_general)
|
self.nav_buttons.append(btn_general)
|
||||||
|
|
||||||
# RPG Maker MV/MZ button
|
# RPG Maker MV/MZ button
|
||||||
btn_mvmz = self.create_nav_button("🎮", "RPG Maker MV/MZ")
|
btn_mvmz = self.create_nav_button("🎮", "RPG Maker MV/MZ", engine_key="mvmz")
|
||||||
btn_mvmz.clicked.connect(lambda: self.switch_page(1))
|
btn_mvmz.clicked.connect(lambda: self.switch_page(1))
|
||||||
nav_layout.addWidget(btn_mvmz)
|
nav_layout.addWidget(btn_mvmz)
|
||||||
self.nav_buttons.append(btn_mvmz)
|
self.nav_buttons.append(btn_mvmz)
|
||||||
|
|
||||||
# Wolf RPG button
|
# Wolf RPG button
|
||||||
btn_wolf = self.create_nav_button("🐺", "Wolf RPG")
|
btn_wolf = self.create_nav_button("🐺", "Wolf RPG", engine_key="wolf")
|
||||||
btn_wolf.clicked.connect(lambda: self.switch_page(2))
|
btn_wolf.clicked.connect(lambda: self.switch_page(2))
|
||||||
nav_layout.addWidget(btn_wolf)
|
nav_layout.addWidget(btn_wolf)
|
||||||
self.nav_buttons.append(btn_wolf)
|
self.nav_buttons.append(btn_wolf)
|
||||||
|
|
@ -207,7 +207,7 @@ class ConfigTab(QWidget):
|
||||||
self.nav_buttons.append(btn_csv)
|
self.nav_buttons.append(btn_csv)
|
||||||
|
|
||||||
# SRPG Studio button
|
# SRPG Studio button
|
||||||
btn_srpg = self.create_nav_button("⚔️", "SRPG Studio")
|
btn_srpg = self.create_nav_button("⚔️", "SRPG Studio", engine_key="srpg")
|
||||||
btn_srpg.clicked.connect(lambda: self.switch_page(4))
|
btn_srpg.clicked.connect(lambda: self.switch_page(4))
|
||||||
nav_layout.addWidget(btn_srpg)
|
nav_layout.addWidget(btn_srpg)
|
||||||
self.nav_buttons.append(btn_srpg)
|
self.nav_buttons.append(btn_srpg)
|
||||||
|
|
@ -246,7 +246,7 @@ class ConfigTab(QWidget):
|
||||||
# Select first page by default
|
# Select first page by default
|
||||||
self.switch_page(0)
|
self.switch_page(0)
|
||||||
|
|
||||||
def create_nav_button(self, icon_text, tooltip):
|
def create_nav_button(self, icon_text, tooltip, engine_key=None):
|
||||||
"""Create a navigation button for the top bar."""
|
"""Create a navigation button for the top bar."""
|
||||||
from gui.platform_glyph import configure_nav_toolbutton
|
from gui.platform_glyph import configure_nav_toolbutton
|
||||||
|
|
||||||
|
|
@ -254,7 +254,9 @@ class ConfigTab(QWidget):
|
||||||
btn.setToolTip(tooltip)
|
btn.setToolTip(tooltip)
|
||||||
btn.setFixedSize(50, 50)
|
btn.setFixedSize(50, 50)
|
||||||
btn.setCheckable(True)
|
btn.setCheckable(True)
|
||||||
configure_nav_toolbutton(btn, icon_text, horizontal=True)
|
configure_nav_toolbutton(
|
||||||
|
btn, icon_text, horizontal=True, engine_key=engine_key,
|
||||||
|
)
|
||||||
return btn
|
return btn
|
||||||
|
|
||||||
def switch_page(self, index):
|
def switch_page(self, index):
|
||||||
|
|
|
||||||
|
|
@ -153,8 +153,24 @@ def configure_nav_toolbutton(
|
||||||
*,
|
*,
|
||||||
horizontal: bool = False,
|
horizontal: bool = False,
|
||||||
update_available: bool = False,
|
update_available: bool = False,
|
||||||
|
engine_key: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Apply nav icon — on Linux render BMP symbols into fixed pixmaps for alignment."""
|
"""Apply nav icon - engine PNG when available, else emoji/glyph fallback."""
|
||||||
|
from util.paths import ENGINE_ICONS_DIR
|
||||||
|
|
||||||
|
engine_png = (ENGINE_ICONS_DIR / f"{engine_key}.png") if engine_key else None
|
||||||
|
if engine_png is not None and engine_png.is_file():
|
||||||
|
icon_dim = max(28, min(btn.width(), btn.height()) - 8)
|
||||||
|
icon = QIcon(str(engine_png))
|
||||||
|
btn.setIcon(icon)
|
||||||
|
btn.setIconSize(QSize(icon_dim, icon_dim))
|
||||||
|
btn.setText("")
|
||||||
|
btn.setToolButtonStyle(Qt.ToolButtonIconOnly)
|
||||||
|
btn.setStyleSheet(_nav_toolbutton_stylesheet(
|
||||||
|
horizontal=horizontal, icon_only=True, update_available=update_available,
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
if sys.platform.startswith("linux"):
|
if sys.platform.startswith("linux"):
|
||||||
# Scale to the button cell (leave room for the 3px active border).
|
# Scale to the button cell (leave room for the 3px active border).
|
||||||
icon_dim = max(28, min(btn.width(), btn.height()) - 8)
|
icon_dim = max(28, min(btn.width(), btn.height()) - 8)
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ from gui.workflow_tab import (
|
||||||
_make_btn,
|
_make_btn,
|
||||||
_make_hr,
|
_make_hr,
|
||||||
_make_icon_btn,
|
_make_icon_btn,
|
||||||
|
_make_text_btn,
|
||||||
_make_section_label,
|
_make_section_label,
|
||||||
)
|
)
|
||||||
from util import wolf_names
|
from util import wolf_names
|
||||||
|
|
@ -821,17 +822,17 @@ class WolfWorkflowTab(QWidget):
|
||||||
|
|
||||||
row1 = QHBoxLayout()
|
row1 = QHBoxLayout()
|
||||||
row1.setSpacing(6)
|
row1.setSpacing(6)
|
||||||
select_all_btn = _make_icon_btn("✓", "Select all importable files")
|
select_all_btn = _make_text_btn("All", "Select all importable files", min_width=44)
|
||||||
select_all_btn.clicked.connect(self._select_all_files)
|
select_all_btn.clicked.connect(self._select_all_files)
|
||||||
row1.addWidget(select_all_btn)
|
row1.addWidget(select_all_btn)
|
||||||
deselect_all_btn = _make_icon_btn("✗", "Deselect all files")
|
deselect_all_btn = _make_text_btn("None", "Deselect all files", min_width=52)
|
||||||
deselect_all_btn.clicked.connect(self._deselect_all_files)
|
deselect_all_btn.clicked.connect(self._deselect_all_files)
|
||||||
row1.addWidget(deselect_all_btn)
|
row1.addWidget(deselect_all_btn)
|
||||||
sel_core = _make_icon_btn("◆", "Core only: databases, common events, names")
|
sel_core = _make_text_btn("Core", "Core only: databases, common events, names", min_width=52)
|
||||||
sel_core.setToolTip("Select core JSON files; deselect map scripts")
|
sel_core.setToolTip("Select core JSON files; deselect map scripts")
|
||||||
sel_core.clicked.connect(self._select_core_only)
|
sel_core.clicked.connect(self._select_core_only)
|
||||||
row1.addWidget(sel_core)
|
row1.addWidget(sel_core)
|
||||||
import_btn = _make_icon_btn("📥", "Import selected files into files/")
|
import_btn = _make_text_btn("Import", "Import selected files into files/", min_width=64)
|
||||||
import_btn.setEnabled(False)
|
import_btn.setEnabled(False)
|
||||||
import_btn.setToolTip("Replace files/ with exactly the checked files above")
|
import_btn.setToolTip("Replace files/ with exactly the checked files above")
|
||||||
import_btn.clicked.connect(lambda _checked=False: self._import_files())
|
import_btn.clicked.connect(lambda _checked=False: self._import_files())
|
||||||
|
|
|
||||||
|
|
@ -446,8 +446,30 @@ def _make_btn(text: str, color: str = "#007acc") -> QPushButton:
|
||||||
return btn
|
return btn
|
||||||
|
|
||||||
|
|
||||||
|
_ACTION_BTN_STYLE = (
|
||||||
|
"QPushButton{background-color:#2d2d30;color:white;"
|
||||||
|
"font-weight:bold;font-size:12px;border:1px solid #555555;"
|
||||||
|
"border-radius:4px;padding:4px 10px;"
|
||||||
|
"min-height:36px;max-height:36px;}"
|
||||||
|
"QPushButton:hover{background-color:#3e3e42;border-color:#007acc;}"
|
||||||
|
"QPushButton:pressed{background-color:#007acc;}"
|
||||||
|
"QPushButton:disabled{background-color:#2d2d30;color:#555555;border-color:#444444;}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_text_btn(label: str, tooltip: str = "", *, min_width: int = 52) -> QPushButton:
|
||||||
|
"""Compact labeled button for file-list actions (All / None / Core / Import)."""
|
||||||
|
btn = QPushButton(label)
|
||||||
|
btn.setToolTip(tooltip)
|
||||||
|
btn.setFont(QFont("Segoe UI", 10))
|
||||||
|
btn.setMinimumWidth(min_width)
|
||||||
|
btn.setFixedHeight(36)
|
||||||
|
btn.setStyleSheet(_ACTION_BTN_STYLE)
|
||||||
|
return btn
|
||||||
|
|
||||||
|
|
||||||
def _make_icon_btn(icon_text: str, tooltip: str = "") -> QPushButton:
|
def _make_icon_btn(icon_text: str, tooltip: str = "") -> QPushButton:
|
||||||
"""Compact icon-only button matching the Translation tab action buttons."""
|
"""Compact icon-only button (e.g. folder browse)."""
|
||||||
from gui.platform_glyph import platform_glyph
|
from gui.platform_glyph import platform_glyph
|
||||||
|
|
||||||
btn = QPushButton(platform_glyph(icon_text))
|
btn = QPushButton(platform_glyph(icon_text))
|
||||||
|
|
@ -859,20 +881,20 @@ class WorkflowTab(QWidget):
|
||||||
# Action row
|
# Action row
|
||||||
row1 = QHBoxLayout()
|
row1 = QHBoxLayout()
|
||||||
row1.setSpacing(6)
|
row1.setSpacing(6)
|
||||||
select_all_btn = _make_icon_btn("✓", "Select all importable files")
|
select_all_btn = _make_text_btn("All", "Select all importable files", min_width=44)
|
||||||
select_all_btn.clicked.connect(self._select_all_files)
|
select_all_btn.clicked.connect(self._select_all_files)
|
||||||
row1.addWidget(select_all_btn)
|
row1.addWidget(select_all_btn)
|
||||||
|
|
||||||
deselect_all_btn = _make_icon_btn("✗", "Deselect all files")
|
deselect_all_btn = _make_text_btn("None", "Deselect all files", min_width=52)
|
||||||
deselect_all_btn.clicked.connect(self._deselect_all_files)
|
deselect_all_btn.clicked.connect(self._deselect_all_files)
|
||||||
row1.addWidget(deselect_all_btn)
|
row1.addWidget(deselect_all_btn)
|
||||||
|
|
||||||
sel_core = _make_icon_btn("◆", "Core Only: select database files and deselect maps")
|
sel_core = _make_text_btn("Core", "Core only: select database files and deselect maps", min_width=52)
|
||||||
sel_core.setToolTip("Select only core database files; deselect map files")
|
sel_core.setToolTip("Select only core database files; deselect map files")
|
||||||
sel_core.clicked.connect(self._select_core_only)
|
sel_core.clicked.connect(self._select_core_only)
|
||||||
row1.addWidget(sel_core)
|
row1.addWidget(sel_core)
|
||||||
|
|
||||||
import_btn = _make_icon_btn("📥", "Import selected files into files/")
|
import_btn = _make_text_btn("Import", "Import selected files into files/", min_width=64)
|
||||||
import_btn.setEnabled(False)
|
import_btn.setEnabled(False)
|
||||||
import_btn.setToolTip("Replace files/ with exactly the checked files above")
|
import_btn.setToolTip("Replace files/ with exactly the checked files above")
|
||||||
import_btn.clicked.connect(lambda _checked=False: self._import_files())
|
import_btn.clicked.connect(lambda _checked=False: self._import_files())
|
||||||
|
|
|
||||||
101
scripts/extract_engine_icons.py
Normal file
101
scripts/extract_engine_icons.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Extract engine icons from Game.exe files into assets/engine_icons/.
|
||||||
|
|
||||||
|
Default search paths cover common locations on this machine; override with flags:
|
||||||
|
|
||||||
|
python scripts/extract_engine_icons.py
|
||||||
|
python scripts/extract_engine_icons.py --ace /path/to/ace/Game.exe --srpg /path/to/srpg/Game.exe
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from util.pe_icon import extract_best_icon_png # noqa: E402
|
||||||
|
from util.paths import ENGINE_ICONS_DIR # noqa: E402
|
||||||
|
|
||||||
|
# Known-good sources for bundled engine artwork (first existing path wins).
|
||||||
|
_DEFAULT_SOURCES: dict[str, list[Path]] = {
|
||||||
|
"mvmz": [
|
||||||
|
Path.home() / "DazedTranslations/Games/Shrift II/Game.exe",
|
||||||
|
],
|
||||||
|
"wolf": [
|
||||||
|
Path.home() / "DazedTranslations/Tools/WOLF/Editor/Editor 2.24Z.exe",
|
||||||
|
Path.home() / "DazedTranslations/Tools/WOLF/Editor/Game.exe",
|
||||||
|
Path.home()
|
||||||
|
/ "DazedTranslations/Games/[Ryuugames] RY-RJ01633249/RJ01633249/銀眼のライトナ/Game.exe",
|
||||||
|
],
|
||||||
|
"ace": [
|
||||||
|
# VX Ace Game.exe (RGSS3 player) - no default install on this machine.
|
||||||
|
],
|
||||||
|
"srpg": [
|
||||||
|
# SRPG Studio Game.exe - no default install on this machine.
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_source(engine: str, overrides: dict[str, Path | None]) -> Path | None:
|
||||||
|
if overrides.get(engine):
|
||||||
|
return overrides[engine]
|
||||||
|
for candidate in _DEFAULT_SOURCES.get(engine, []):
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_engine_icons(
|
||||||
|
overrides: dict[str, Path | None] | None = None,
|
||||||
|
*,
|
||||||
|
dest_dir: Path | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Extract icons for each engine; return ``{engine: status_message}``."""
|
||||||
|
overrides = overrides or {}
|
||||||
|
dest_dir = dest_dir or ENGINE_ICONS_DIR
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
results: dict[str, str] = {}
|
||||||
|
|
||||||
|
for engine in ("mvmz", "wolf", "ace", "srpg"):
|
||||||
|
src = _resolve_source(engine, overrides)
|
||||||
|
out = dest_dir / f"{engine}.png"
|
||||||
|
if src is None:
|
||||||
|
results[engine] = "skipped (no source .exe; pass --{0} PATH)".format(engine)
|
||||||
|
continue
|
||||||
|
size = extract_best_icon_png(src, out, min_size=64 if engine == "wolf" else 0)
|
||||||
|
if size is None:
|
||||||
|
results[engine] = f"failed (no icon in {src})"
|
||||||
|
else:
|
||||||
|
results[engine] = f"ok {size[0]}x{size[1]} from {src}"
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--mvmz", type=Path, help="RPG Maker MV/MZ Game.exe")
|
||||||
|
parser.add_argument("--wolf", type=Path, help="Wolf RPG Editor or game Game.exe")
|
||||||
|
parser.add_argument("--ace", type=Path, help="RPG Maker VX Ace Game.exe")
|
||||||
|
parser.add_argument("--srpg", type=Path, help="SRPG Studio Game.exe")
|
||||||
|
parser.add_argument("--dest", type=Path, default=ENGINE_ICONS_DIR, help="Output directory")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
overrides = {
|
||||||
|
"mvmz": args.mvmz,
|
||||||
|
"wolf": args.wolf,
|
||||||
|
"ace": args.ace,
|
||||||
|
"srpg": args.srpg,
|
||||||
|
}
|
||||||
|
results = extract_engine_icons(overrides, dest_dir=args.dest)
|
||||||
|
failed = 0
|
||||||
|
for engine, msg in results.items():
|
||||||
|
print(f"{engine}: {msg}")
|
||||||
|
if msg.startswith("failed") or (msg.startswith("skipped") and engine in ("mvmz", "wolf")):
|
||||||
|
failed += 1
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
44
tests/test_pe_icon.py
Normal file
44
tests/test_pe_icon.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Tests for PE executable icon extraction."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from util.pe_icon import extract_best_icon_png, extract_icon_blobs
|
||||||
|
|
||||||
|
_MVMZ_EXE = Path.home() / "DazedTranslations/Games/Shrift II/Game.exe"
|
||||||
|
_WOLF_EXE = Path.home() / "DazedTranslations/Tools/WOLF/Editor/Editor 2.24Z.exe"
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(_MVMZ_EXE.is_file(), "Shrift II Game.exe not available")
|
||||||
|
class TestPeIconExtraction(unittest.TestCase):
|
||||||
|
def test_extract_mvmz_icon(self) -> None:
|
||||||
|
blobs = extract_icon_blobs(_MVMZ_EXE)
|
||||||
|
self.assertGreater(len(blobs), 0)
|
||||||
|
|
||||||
|
def test_extract_mvmz_png(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
out = Path(tmp) / "mvmz.png"
|
||||||
|
size = extract_best_icon_png(_MVMZ_EXE, out)
|
||||||
|
self.assertIsNotNone(size)
|
||||||
|
assert size is not None
|
||||||
|
self.assertGreaterEqual(size[0], 32)
|
||||||
|
self.assertTrue(out.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(_WOLF_EXE.is_file(), "Wolf Editor not available")
|
||||||
|
class TestWolfIconExtraction(unittest.TestCase):
|
||||||
|
def test_extract_wolf_png(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
out = Path(tmp) / "wolf.png"
|
||||||
|
size = extract_best_icon_png(_WOLF_EXE, out)
|
||||||
|
self.assertIsNotNone(size)
|
||||||
|
assert size is not None
|
||||||
|
self.assertGreaterEqual(size[0], 16)
|
||||||
|
self.assertTrue(out.is_file())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -12,6 +12,7 @@ PROMPT_PATH = DATA_DIR / "prompt.txt"
|
||||||
LAST_UPDATE_SHA_PATH = DATA_DIR / "last_update_sha.txt"
|
LAST_UPDATE_SHA_PATH = DATA_DIR / "last_update_sha.txt"
|
||||||
ENV_PATH = PROJECT_ROOT / ".env"
|
ENV_PATH = PROJECT_ROOT / ".env"
|
||||||
ICON_PATH = PROJECT_ROOT / "assets" / "icon.png"
|
ICON_PATH = PROJECT_ROOT / "assets" / "icon.png"
|
||||||
|
ENGINE_ICONS_DIR = PROJECT_ROOT / "assets" / "engine_icons"
|
||||||
|
|
||||||
_ROOT_DATA_FILES = (
|
_ROOT_DATA_FILES = (
|
||||||
"vocab.txt",
|
"vocab.txt",
|
||||||
|
|
|
||||||
107
util/pe_icon.py
Normal file
107
util/pe_icon.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"""Extract the largest embedded icon from a Windows PE executable as PNG."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pefile
|
||||||
|
except ImportError: # pragma: no cover - pefile is a dev/runtime dependency
|
||||||
|
pefile = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_icon_to_image(raw: bytes) -> Image.Image | None:
|
||||||
|
"""Decode a single RT_ICON blob (PNG-embedded or BMP) to RGBA."""
|
||||||
|
png_start = raw.find(b"\x89PNG")
|
||||||
|
if png_start >= 0:
|
||||||
|
return Image.open(io.BytesIO(raw[png_start:])).convert("RGBA")
|
||||||
|
|
||||||
|
if len(raw) < 40:
|
||||||
|
return None
|
||||||
|
|
||||||
|
bi_size = struct.unpack("<I", raw[0:4])[0]
|
||||||
|
bi_width = struct.unpack("<i", raw[4:8])[0]
|
||||||
|
bi_height = struct.unpack("<i", raw[8:12])[0]
|
||||||
|
bi_bpp = struct.unpack("<H", raw[14:16])[0]
|
||||||
|
actual_h = abs(bi_height) // 2
|
||||||
|
if bi_width <= 0 or actual_h <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
pixel_offset = bi_size
|
||||||
|
if bi_bpp <= 8:
|
||||||
|
num_colors = struct.unpack("<I", raw[32:36])[0] or (2**bi_bpp)
|
||||||
|
pixel_offset += num_colors * 4
|
||||||
|
|
||||||
|
row_bytes = ((bi_width * bi_bpp + 31) // 32) * 4
|
||||||
|
xor_data = raw[pixel_offset : pixel_offset + row_bytes * actual_h]
|
||||||
|
|
||||||
|
if bi_bpp == 32:
|
||||||
|
img = Image.frombytes("RGBA", (bi_width, actual_h), xor_data[: bi_width * actual_h * 4])
|
||||||
|
elif bi_bpp == 24:
|
||||||
|
img = Image.frombytes("RGB", (bi_width, actual_h), xor_data[: bi_width * actual_h * 3]).convert("RGBA")
|
||||||
|
elif bi_bpp == 8:
|
||||||
|
num_colors = struct.unpack("<I", raw[32:36])[0] or 256
|
||||||
|
palette: list[int] = []
|
||||||
|
for c in range(num_colors):
|
||||||
|
b, g, r, _ = struct.unpack("BBBB", raw[40 + c * 4 : 44 + c * 4])
|
||||||
|
palette.extend([r, g, b])
|
||||||
|
img = Image.frombytes("P", (bi_width, actual_h), xor_data[: bi_width * actual_h])
|
||||||
|
img.putpalette(palette)
|
||||||
|
img = img.convert("RGBA")
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return img.transpose(Image.FLIP_TOP_BOTTOM)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_icon_blobs(exe_path: str | Path) -> dict[int, bytes]:
|
||||||
|
"""Return ``{resource_id: raw_icon_bytes}`` from *exe_path*."""
|
||||||
|
if pefile is None:
|
||||||
|
raise RuntimeError("pefile is required to extract icons from executables")
|
||||||
|
|
||||||
|
pe = pefile.PE(str(exe_path), fast_load=True)
|
||||||
|
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
|
||||||
|
|
||||||
|
blobs: dict[int, bytes] = {}
|
||||||
|
if hasattr(pe, "DIRECTORY_ENTRY_RESOURCE"):
|
||||||
|
for entry in pe.DIRECTORY_ENTRY_RESOURCE.entries:
|
||||||
|
if entry.id != pefile.RESOURCE_TYPE["RT_ICON"]:
|
||||||
|
continue
|
||||||
|
for icon_entry in entry.directory.entries:
|
||||||
|
icon_id = icon_entry.struct.Id
|
||||||
|
for data_entry in icon_entry.directory.entries:
|
||||||
|
offset = data_entry.data.struct.OffsetToData
|
||||||
|
size = data_entry.data.struct.Size
|
||||||
|
blobs[icon_id] = pe.get_data(offset, size)
|
||||||
|
pe.close()
|
||||||
|
return blobs
|
||||||
|
|
||||||
|
|
||||||
|
def extract_best_icon_image(exe_path: str | Path) -> Image.Image | None:
|
||||||
|
"""Return the largest decodable icon from *exe_path*, or ``None``."""
|
||||||
|
blobs = extract_icon_blobs(exe_path)
|
||||||
|
if not blobs:
|
||||||
|
return None
|
||||||
|
|
||||||
|
best_raw = max(blobs.values(), key=len)
|
||||||
|
return _raw_icon_to_image(best_raw)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_best_icon_png(exe_path: str | Path, out_path: str | Path, *, min_size: int = 0) -> tuple[int, int] | None:
|
||||||
|
"""Write the largest icon from *exe_path* to *out_path*; return size or ``None``."""
|
||||||
|
img = extract_best_icon_image(exe_path)
|
||||||
|
if img is None:
|
||||||
|
return None
|
||||||
|
if min_size and max(img.size) < min_size:
|
||||||
|
scale = min_size / max(img.size)
|
||||||
|
new_w = max(1, int(round(img.width * scale)))
|
||||||
|
new_h = max(1, int(round(img.height * scale)))
|
||||||
|
img = img.resize((new_w, new_h), Image.Resampling.NEAREST)
|
||||||
|
out_path = Path(out_path)
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
img.save(out_path)
|
||||||
|
return img.size
|
||||||
Loading…
Reference in a new issue