286 lines
8.9 KiB
Python
286 lines
8.9 KiB
Python
"""
|
|
EU-Utility - Icon Extractor Integration
|
|
|
|
Integrates TGA icon extraction from Lemontropia Suite's Icon Extractor.
|
|
Provides icon cache management and TGA to PNG conversion.
|
|
"""
|
|
|
|
import struct
|
|
from pathlib import Path
|
|
from io import BytesIO
|
|
from typing import Optional, Tuple, Any
|
|
try:
|
|
from PIL import Image
|
|
PIL_AVAILABLE = True
|
|
except ImportError:
|
|
PIL_AVAILABLE = False
|
|
print("[IconExtractor] PIL not available. Install: pip install Pillow")
|
|
|
|
|
|
class TGAReader:
|
|
"""Read TGA files from Entropia Universe cache."""
|
|
|
|
TGA_TYPES = {
|
|
0: 'NO_IMAGE',
|
|
1: 'COLOR_MAPPED',
|
|
2: 'RGB',
|
|
3: 'GRAYSCALE',
|
|
9: 'RLE_COLOR_MAPPED',
|
|
10: 'RLE_RGB',
|
|
11: 'RLE_GRAYSCALE',
|
|
}
|
|
|
|
def __init__(self, file_path: Path):
|
|
self.file_path = file_path
|
|
self.header = None
|
|
self.width = 0
|
|
self.height = 0
|
|
self.pixels = None
|
|
|
|
def read(self) -> Optional[Any]: # Returns Optional[Image.Image]
|
|
"""Read TGA file and return PIL Image."""
|
|
if not PIL_AVAILABLE:
|
|
return None
|
|
|
|
# Import Image here to avoid NameError
|
|
from PIL import Image
|
|
|
|
try:
|
|
with open(self.file_path, 'rb') as f:
|
|
data = f.read()
|
|
|
|
return self._parse_tga(data)
|
|
|
|
except Exception as e:
|
|
print(f"[TGAReader] Error reading {self.file_path}: {e}")
|
|
return None
|
|
|
|
def _parse_tga(self, data: bytes) -> Optional[Image.Image]:
|
|
"""Parse TGA data."""
|
|
if len(data) < 18:
|
|
return None
|
|
|
|
# Read header
|
|
id_length = data[0]
|
|
color_map_type = data[1]
|
|
image_type = data[2]
|
|
|
|
# Color map spec (5 bytes)
|
|
color_map_origin = struct.unpack('<H', data[3:5])[0]
|
|
color_map_length = struct.unpack('<H', data[5:7])[0]
|
|
color_map_entry_size = data[7]
|
|
|
|
# Image spec (10 bytes)
|
|
x_origin = struct.unpack('<H', data[8:10])[0]
|
|
y_origin = struct.unpack('<H', data[10:12])[0]
|
|
self.width = struct.unpack('<H', data[12:14])[0]
|
|
self.height = struct.unpack('<H', data[14:16])[0]
|
|
pixel_depth = data[16]
|
|
descriptor = data[17]
|
|
|
|
# Skip ID and color map
|
|
offset = 18 + id_length
|
|
if color_map_type == 1:
|
|
offset += color_map_length * (color_map_entry_size // 8)
|
|
|
|
# Read pixel data
|
|
pixel_data = data[offset:]
|
|
|
|
# Create image based on type
|
|
if image_type in (2, 10): # RGB or RLE RGB
|
|
return self._read_rgb(pixel_data, pixel_depth, image_type == 10)
|
|
elif image_type in (3, 11): # Grayscale
|
|
return self._read_grayscale(pixel_data, image_type == 11)
|
|
|
|
return None
|
|
|
|
def _read_rgb(self, data: bytes, depth: int, rle: bool) -> Optional[Any]: # Optional[Image.Image]
|
|
"""Read RGB pixel data."""
|
|
if not PIL_AVAILABLE:
|
|
return None
|
|
|
|
from PIL import Image
|
|
|
|
if depth not in (24, 32):
|
|
return None
|
|
|
|
bytes_per_pixel = depth // 8
|
|
expected_size = self.width * self.height * bytes_per_pixel
|
|
|
|
if rle:
|
|
data = self._decode_rle(data, bytes_per_pixel)
|
|
|
|
if len(data) < expected_size:
|
|
return None
|
|
|
|
# Convert BGR to RGB
|
|
if bytes_per_pixel == 3:
|
|
mode = 'RGB'
|
|
pixels = bytearray()
|
|
for i in range(0, len(data), 3):
|
|
if i + 2 < len(data):
|
|
pixels.extend([data[i+2], data[i+1], data[i]]) # BGR to RGB
|
|
else:
|
|
mode = 'RGBA'
|
|
pixels = bytearray()
|
|
for i in range(0, len(data), 4):
|
|
if i + 3 < len(data):
|
|
pixels.extend([data[i+2], data[i+1], data[i], data[i+3]]) # BGRA to RGBA
|
|
|
|
try:
|
|
img = Image.frombytes(mode, (self.width, self.height), bytes(pixels))
|
|
# Flip vertically (TGA stores bottom-to-top)
|
|
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
|
|
return img
|
|
except Exception as e:
|
|
print(f"[TGAReader] Error creating image: {e}")
|
|
return None
|
|
|
|
def _read_grayscale(self, data: bytes, rle: bool) -> Optional[Any]: # Optional[Image.Image]
|
|
"""Read grayscale pixel data."""
|
|
if not PIL_AVAILABLE:
|
|
return None
|
|
|
|
from PIL import Image
|
|
|
|
expected_size = self.width * self.height
|
|
|
|
if rle:
|
|
data = self._decode_rle(data, 1)
|
|
|
|
if len(data) < expected_size:
|
|
return None
|
|
|
|
try:
|
|
img = Image.frombytes('L', (self.width, self.height), data[:expected_size])
|
|
img = img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
|
|
return img
|
|
except Exception as e:
|
|
print(f"[TGAReader] Error creating grayscale image: {e}")
|
|
return None
|
|
|
|
def _decode_rle(self, data: bytes, bytes_per_pixel: int) -> bytes:
|
|
"""Decode RLE compressed data."""
|
|
result = bytearray()
|
|
i = 0
|
|
|
|
while i < len(data):
|
|
header = data[i]
|
|
i += 1
|
|
|
|
if header >= 128: # Run-length packet
|
|
count = (header - 128) + 1
|
|
pixel = data[i:i + bytes_per_pixel]
|
|
i += bytes_per_pixel
|
|
for _ in range(count):
|
|
result.extend(pixel)
|
|
else: # Raw packet
|
|
count = header + 1
|
|
for _ in range(count):
|
|
result.extend(data[i:i + bytes_per_pixel])
|
|
i += bytes_per_pixel
|
|
|
|
return bytes(result)
|
|
|
|
|
|
class IconCacheManager:
|
|
"""Manages EU icon cache extraction and storage."""
|
|
|
|
def __init__(self, cache_dir: str = "assets/eu_icons"):
|
|
self.cache_dir = Path(cache_dir)
|
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
self.icon_index = {}
|
|
self._load_index()
|
|
|
|
def _load_index(self):
|
|
"""Load icon index."""
|
|
index_file = self.cache_dir / "index.json"
|
|
if index_file.exists():
|
|
try:
|
|
import json
|
|
with open(index_file, 'r') as f:
|
|
self.icon_index = json.load(f)
|
|
except:
|
|
self.icon_index = {}
|
|
|
|
def _save_index(self):
|
|
"""Save icon index."""
|
|
import json
|
|
index_file = self.cache_dir / "index.json"
|
|
with open(index_file, 'w') as f:
|
|
json.dump(self.icon_index, f, indent=2)
|
|
|
|
def extract_from_tga(self, tga_path: Path, icon_name: str) -> Optional[Path]:
|
|
"""Extract icon from TGA file and save as PNG."""
|
|
if not PIL_AVAILABLE:
|
|
return None
|
|
|
|
reader = TGAReader(tga_path)
|
|
img = reader.read()
|
|
|
|
if img:
|
|
# Save as PNG
|
|
output_path = self.cache_dir / f"{icon_name}.png"
|
|
img.save(output_path, 'PNG')
|
|
|
|
# Update index
|
|
self.icon_index[icon_name] = {
|
|
'source': str(tga_path),
|
|
'size': (reader.width, reader.height),
|
|
'cached': str(output_path)
|
|
}
|
|
self._save_index()
|
|
|
|
return output_path
|
|
|
|
return None
|
|
|
|
def get_icon(self, icon_name: str, size: Tuple[int, int] = (32, 32)) -> Optional[Path]:
|
|
"""Get icon path, extracting from TGA if needed."""
|
|
# Check cache
|
|
cached = self.cache_dir / f"{icon_name}.png"
|
|
if cached.exists():
|
|
return cached
|
|
|
|
# Check if we have source TGA
|
|
if icon_name in self.icon_index:
|
|
source = Path(self.icon_index[icon_name]['source'])
|
|
if source.exists():
|
|
return self.extract_from_tga(source, icon_name)
|
|
|
|
return None
|
|
|
|
def scan_cache_directory(self, eu_cache_path: Path):
|
|
"""Scan EU cache directory for TGA icons."""
|
|
if not eu_cache_path.exists():
|
|
return
|
|
|
|
print(f"[IconCache] Scanning {eu_cache_path}...")
|
|
|
|
found = 0
|
|
for tga_file in eu_cache_path.rglob("*.tga"):
|
|
# Try to extract
|
|
icon_name = tga_file.stem
|
|
if self.extract_from_tga(tga_file, icon_name):
|
|
found += 1
|
|
|
|
print(f"[IconCache] Extracted {found} icons")
|
|
|
|
def list_icons(self) -> list:
|
|
"""List all available cached icons."""
|
|
icons = []
|
|
for png_file in self.cache_dir.glob("*.png"):
|
|
icons.append(png_file.stem)
|
|
return sorted(icons)
|
|
|
|
|
|
# Singleton instance
|
|
_icon_cache = None
|
|
|
|
def get_icon_cache() -> IconCacheManager:
|
|
"""Get global icon cache instance."""
|
|
global _icon_cache
|
|
if _icon_cache is None:
|
|
_icon_cache = IconCacheManager()
|
|
return _icon_cache
|