Files

919 lines
37 KiB
Python

import struct
import os
from typing import List, Dict, Optional, Tuple
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont
import re
class DCIReader:
"""DCI file reader and parser"""
MAGIC = b'DCI\x00'
# File types
FILE_TYPE_RESERVED = 0
FILE_TYPE_FILE = 1
FILE_TYPE_DIRECTORY = 2
FILE_TYPE_LINK = 3
def __init__(self, file_path: str = None, binary_data: bytes = None):
self.file_path = file_path
self.binary_data = binary_data
self.files = []
self.directory_structure = {}
def read(self) -> bool:
"""Read and parse DCI file from file path or binary data"""
try:
if self.binary_data is not None:
return self._read_from_binary_data()
elif self.file_path is not None:
return self._read_from_file()
else:
raise ValueError("Either file_path or binary_data must be provided")
except Exception as e:
print(f"Error reading DCI file: {e}")
return False
def _read_from_file(self) -> bool:
"""Read and parse DCI file from file path"""
with open(self.file_path, 'rb') as f:
return self._read_from_stream(f)
def _read_from_binary_data(self) -> bool:
"""Read and parse DCI file from binary data"""
stream = BytesIO(self.binary_data)
return self._read_from_stream(stream)
def _read_from_stream(self, stream) -> bool:
"""Read and parse DCI file from a stream"""
# Read header
magic = stream.read(4)
if magic != self.MAGIC:
raise ValueError(f"Invalid DCI file: wrong magic header {magic}")
version = struct.unpack('<B', stream.read(1))[0]
if version != 1:
raise ValueError(f"Unsupported DCI version: {version}")
# Read file count (3 bytes)
file_count_bytes = stream.read(3) + b'\x00'
file_count = struct.unpack('<I', file_count_bytes)[0]
# Read files
for i in range(file_count):
file_info = self._read_file_entry(stream)
self.files.append(file_info)
# Parse directory structure
self._parse_directory_structure()
return True
def _read_file_entry(self, f) -> Dict:
"""Read a single file entry from DCI"""
# File type (1 byte)
file_type = struct.unpack('<B', f.read(1))[0]
# File name (63 bytes, null-terminated)
name_bytes = f.read(63)
name = name_bytes.rstrip(b'\x00').decode('utf-8')
# Content size (8 bytes)
content_size = struct.unpack('<Q', f.read(8))[0]
# Content
content = f.read(content_size)
return {
'name': name,
'type': file_type,
'size': content_size,
'content': content
}
def _parse_directory_structure(self):
"""Parse the directory structure from files"""
for file_info in self.files:
if file_info['type'] == self.FILE_TYPE_DIRECTORY:
self._parse_directory_content(file_info['name'], file_info['content'])
def _parse_directory_content(self, dir_name: str, content: bytes):
"""Parse directory content recursively"""
if dir_name not in self.directory_structure:
self.directory_structure[dir_name] = {}
content_stream = BytesIO(content)
while content_stream.tell() < len(content):
try:
# Read file type
file_type_bytes = content_stream.read(1)
if not file_type_bytes:
break
file_type = struct.unpack('<B', file_type_bytes)[0]
# Read file name
name_bytes = content_stream.read(63)
if len(name_bytes) < 63:
break
name = name_bytes.rstrip(b'\x00').decode('utf-8')
# Read content size
size_bytes = content_stream.read(8)
if len(size_bytes) < 8:
break
size = struct.unpack('<Q', size_bytes)[0]
# Read content
file_content = content_stream.read(size)
if len(file_content) < size:
break
if file_type == self.FILE_TYPE_DIRECTORY:
# Recursively parse subdirectory
subdir_path = f"{dir_name}/{name}"
self._parse_directory_content(subdir_path, file_content)
else:
# Store file info
self.directory_structure[dir_name][name] = {
'type': file_type,
'size': size,
'content': file_content
}
except Exception as e:
print(f"Error parsing directory content: {e}")
break
def get_icon_images(self) -> List[Dict]:
"""Extract all icon images with metadata"""
images = []
print("DCIReader.get_icon_images: 开始提取图像...")
for dir_path, files in self.directory_structure.items():
# Parse directory path to extract metadata
path_parts = dir_path.split('/')
print(f"处理目录: {dir_path}, 包含 {len(files)} 个文件")
if len(path_parts) >= 3:
# Expected format: size/state.tone/scale
size_str = path_parts[0]
state_tone_str = path_parts[1] if len(path_parts) > 1 else ""
scale_str = path_parts[2] if len(path_parts) > 2 else ""
# Parse state and tone
state, tone = self._parse_state_tone(state_tone_str)
# Parse size and scale
try:
size = int(size_str) if size_str.isdigit() else 0
# Support both integer and float scale values
try:
scale = float(scale_str) if scale_str else 1.0
except ValueError:
scale = 1.0
except ValueError:
size = 0
scale = 1.0
# Process files in this directory
for filename, file_info in files.items():
if file_info['type'] == self.FILE_TYPE_FILE:
print(f"处理文件: {filename} (在目录 {dir_path} 中)")
# Parse layer filename for additional metadata
layer_info = self._parse_layer_filename(filename)
try:
# Load image from content
image = Image.open(BytesIO(file_info['content']))
image_info = {
'image': image,
'size': size,
'state': state,
'tone': tone,
'scale': scale,
'format': layer_info.get('format', 'unknown'),
'priority': layer_info.get('priority', 1),
'path': dir_path,
'filename': filename,
'file_size': file_info['size'],
# Layer information
'layer_priority': layer_info.get('priority', 1),
'layer_padding': layer_info.get('padding', 0.0),
'palette_type': layer_info.get('palette_name', 'none'),
'palette_value': layer_info.get('palette', -1),
'hue_adjustment': layer_info.get('hue', 0),
'saturation_adjustment': layer_info.get('saturation', 0),
'brightness_adjustment': layer_info.get('brightness', 0),
'red_adjustment': layer_info.get('red', 0),
'green_adjustment': layer_info.get('green', 0),
'blue_adjustment': layer_info.get('blue', 0),
'alpha_adjustment': layer_info.get('alpha', 0),
}
# 打印完整的图像信息以进行调试
try:
print(f"添加图像: 路径={image_info['path']}, 文件名={image_info['filename']}")
except Exception:
print(f"添加图像: 路径=<path>, 文件名=<filename>")
images.append(image_info)
except Exception as e:
print(f"Error loading image {filename}: {e}")
elif file_info['type'] == self.FILE_TYPE_LINK:
try:
print(f"处理符号链接: {filename} (在目录 {dir_path} 中)")
except Exception:
print("处理符号链接")
# Get symlink target path
target_path = file_info['content'].decode('utf-8')
try:
print(f"符号链接目标: {target_path}")
except Exception:
print("符号链接目标")
# Resolve symlink target relative to current directory
resolved_image = self._resolve_symlink(dir_path, target_path)
if resolved_image:
# Parse layer filename for additional metadata
layer_info = self._parse_layer_filename(filename)
# Create image info for symlink (reuse resolved image data)
image_info = {
'image': resolved_image['image'],
'size': size,
'state': state,
'tone': tone,
'scale': scale,
'format': layer_info.get('format', 'unknown'),
'priority': layer_info.get('priority', 1),
'path': dir_path,
'filename': filename,
'file_size': resolved_image['file_size'],
'is_symlink': True,
'symlink_target': target_path,
# Layer information
'layer_priority': layer_info.get('priority', 1),
'layer_padding': layer_info.get('padding', 0.0),
'palette_type': layer_info.get('palette_name', 'none'),
'palette_value': layer_info.get('palette', -1),
'hue_adjustment': layer_info.get('hue', 0),
'saturation_adjustment': layer_info.get('saturation', 0),
'brightness_adjustment': layer_info.get('brightness', 0),
'red_adjustment': layer_info.get('red', 0),
'green_adjustment': layer_info.get('green', 0),
'blue_adjustment': layer_info.get('blue', 0),
'alpha_adjustment': layer_info.get('alpha', 0),
}
try:
print(f"添加符号链接图像: 路径={image_info['path']}, 文件名={image_info['filename']}, 目标={target_path}")
except Exception:
print(f"添加符号链接图像: 路径=<path>, 文件名=<filename>, 目标=<target>")
images.append(image_info)
else:
try:
print(f"无法解析符号链接: {filename} -> {target_path}")
except Exception:
print("无法解析符号链接")
print(f"DCIReader.get_icon_images: 共提取 {len(images)} 个图像")
return images
def _parse_state_tone(self, state_tone_str: str) -> Tuple[str, str]:
"""Parse state.tone string"""
if '.' in state_tone_str:
parts = state_tone_str.split('.')
state = parts[0] if parts[0] else 'unknown'
tone = parts[1] if len(parts) > 1 and parts[1] else 'unknown'
else:
state = state_tone_str if state_tone_str else 'unknown'
tone = 'unknown'
return state, tone
def _parse_layer_filename(self, filename: str) -> Dict:
"""Parse layer filename to extract metadata according to DCI specification
Based on official implementation from dtkgui/ddciicon.cpp
Uses step-by-step parsing: Priority -> Format/Alpha8 -> Padding -> Palette
Supports both full format and simplified format:
- Full: priority.padding_with_p.palette.hue_saturation_brightness_red_green_blue_alpha.format[.alpha8]
- Simple: priority.format (e.g., 1.webp)
"""
parts = filename.split('.')
if len(parts) < 2:
return {'format': 'unknown', 'is_alpha8': False}
# Initialize layer info with defaults
layer_info = {
'priority': 1,
'padding': 0,
'palette': -1, # NoPalette
'hue': 0,
'saturation': 0,
'brightness': 0,
'red': 0,
'green': 0,
'blue': 0,
'alpha': 0,
'format': 'unknown',
'is_alpha8': False
}
# Step 1: Priority Step - always process first part
try:
layer_info['priority'] = int(parts[0])
remaining_parts = parts[1:]
except (ValueError, IndexError):
return layer_info # Invalid priority
if not remaining_parts:
return layer_info
# Step 2: Format and Alpha8 Step - always process last part(s)
is_alpha8 = remaining_parts[-1].lower() == 'alpha8'
if is_alpha8:
if len(remaining_parts) >= 2:
layer_info['format'] = remaining_parts[-2] # Just the format, not with .alpha8
layer_info['is_alpha8'] = True
# Remove both format and alpha8 from remaining parts
remaining_parts = remaining_parts[:-2]
else:
return layer_info # Invalid alpha8 format
else:
layer_info['format'] = remaining_parts[-1]
# Remove format from remaining parts
remaining_parts = remaining_parts[:-1]
if not remaining_parts:
return layer_info # Only priority and format, return with defaults
# Step 3: Padding Step - find part ending with 'p'
padding_part = None
for i, part in enumerate(remaining_parts):
if part.endswith('p'):
try:
layer_info['padding'] = int(part[:-1]) # Remove 'p' suffix
padding_part = i
break
except ValueError:
continue
# Remove padding part if found
if padding_part is not None:
remaining_parts.pop(padding_part)
if not remaining_parts:
return layer_info # No palette info, return with defaults
# Step 4: Palette Step - process remaining parts
# Handle both formats:
# 1. Single part with palette and color adjustments: "3_0_0_-10_0_0_0_0"
# 2. Separate parts: "3" and "0_0_-10_0_0_0_0" (our format)
if len(remaining_parts) == 1:
# Single part format (official format)
palette_part = remaining_parts[0]
elif len(remaining_parts) == 2:
# Two parts format (our format): palette and color_adjustments
palette_part = f"{remaining_parts[0]}_{remaining_parts[1]}"
else:
# Fallback to first part only
palette_part = remaining_parts[0] if remaining_parts else ""
# Check if it contains color adjustments (underscore-separated)
if '_' in palette_part:
color_parts = palette_part.split('_')
if len(color_parts) == 8: # role_hue_saturation_lightness_red_green_blue_alpha
try:
layer_info['palette'] = int(color_parts[0])
layer_info['hue'] = int(color_parts[1])
layer_info['saturation'] = int(color_parts[2])
layer_info['brightness'] = int(color_parts[3]) # lightness in original
layer_info['red'] = int(color_parts[4])
layer_info['green'] = int(color_parts[5])
layer_info['blue'] = int(color_parts[6])
layer_info['alpha'] = int(color_parts[7])
except ValueError:
# If parsing fails, treat as simple palette
try:
layer_info['palette'] = int(palette_part.split('_')[0])
except ValueError:
pass
else:
# Invalid color adjustment format, treat as simple palette
try:
layer_info['palette'] = int(palette_part.split('_')[0])
except ValueError:
pass
else:
# Simple palette without color adjustments
try:
layer_info['palette'] = int(palette_part)
except ValueError:
pass
# Validate palette range
if layer_info['palette'] < -1 or layer_info['palette'] > 3:
layer_info['palette'] = -1 # Reset to NoPalette if invalid
# Add palette name for convenience
palette_names = {
-1: "none",
0: "foreground",
1: "background",
2: "highlight_foreground",
3: "highlight"
}
layer_info['palette_name'] = palette_names.get(layer_info['palette'], "unknown")
return layer_info
def _resolve_symlink(self, current_dir: str, target_path: str) -> Optional[Dict]:
"""Resolve symlink target and return image data"""
try:
# Parse target path (e.g., "../../normal.light/1/1.0p.-1.0_0_0_0_0_0_0.webp")
# Split current directory path
current_parts = current_dir.split('/')
# Process target path
target_parts = target_path.split('/')
resolved_parts = current_parts.copy()
for part in target_parts:
if part == '..':
# Go up one directory level
if resolved_parts:
resolved_parts.pop()
elif part == '.':
# Stay in current directory
continue
elif part:
# Add directory or filename
resolved_parts.append(part)
# Reconstruct the resolved path
if len(resolved_parts) < 3:
print(f"Invalid resolved path: {resolved_parts}")
return None
# Extract filename from resolved path
filename = resolved_parts[-1]
resolved_dir = '/'.join(resolved_parts[:-1])
try:
print(f"解析符号链接: {current_dir} + {target_path} -> {resolved_dir}/{filename}")
except Exception:
print("解析符号链接")
# Look for the target file in directory structure
if resolved_dir in self.directory_structure:
files = self.directory_structure[resolved_dir]
if filename in files:
file_info = files[filename]
if file_info['type'] == self.FILE_TYPE_FILE:
# Load image from target file
image = Image.open(BytesIO(file_info['content']))
return {
'image': image,
'file_size': file_info['size'],
'content': file_info['content']
}
else:
try:
print(f"符号链接目标不是文件: {resolved_dir}/{filename}")
except Exception:
print("符号链接目标不是文件")
else:
try:
print(f"符号链接目标文件不存在: {resolved_dir}/{filename}")
print(f"可用文件: {list(files.keys())}")
except Exception:
print("符号链接目标文件不存在")
else:
try:
print(f"符号链接目标目录不存在: {resolved_dir}")
print(f"可用目录: {list(self.directory_structure.keys())}")
except Exception:
print("符号链接目标目录不存在")
return None
except Exception as e:
try:
print(f"解析符号链接时出错: {e}")
except Exception:
print("解析符号链接时出错")
return None
class DCIPreviewGenerator:
"""Generate preview images with metadata annotations"""
def __init__(self, background_color=(240, 240, 240), font_size=12):
self.font_size = font_size
self.margin = 10
# 动态计算label_height,根据字体大小和文本行数
# 5行文本 + 行间距,确保有足够空间显示所有文本(移除了tone和format字段)
self.label_height = max(100, (self.font_size + 2) * 5 + 20) # 5行文本 + 额外空间
self.background_color = background_color
self.text_color = self._get_contrasting_text_color(background_color)
def _get_contrasting_text_color(self, bg_color):
"""Calculate contrasting text color based on background color brightness"""
# Calculate relative luminance using the formula for sRGB
r, g, b = bg_color
# Convert to linear RGB
def to_linear(c):
c = c / 255.0
if c <= 0.03928:
return c / 12.92
else:
return pow((c + 0.055) / 1.055, 2.4)
r_linear = to_linear(r)
g_linear = to_linear(g)
b_linear = to_linear(b)
# Calculate relative luminance
luminance = 0.2126 * r_linear + 0.7152 * g_linear + 0.0722 * b_linear
# Return black for light backgrounds, white for dark backgrounds
if luminance > 0.5:
return (0, 0, 0) # Black text for light background
else:
return (255, 255, 255) # White text for dark background
def create_preview_grid(self, images: List[Dict], grid_cols: int = 4, background_color=None) -> Image.Image:
"""Create a grid preview of all images with metadata"""
if not images:
return self._create_empty_preview(background_color)
# Update background color if provided
if background_color is not None:
self.background_color = background_color
self.text_color = self._get_contrasting_text_color(background_color)
# Sort images by size, state, tone, scale
sorted_images = sorted(images, key=lambda x: (
x['size'], x['state'], x['tone'], x['scale']
))
# Calculate grid dimensions
grid_rows = (len(sorted_images) + grid_cols - 1) // grid_cols
# Find maximum image size for consistent cell sizing
max_image_size = max(img['image'].size[0] for img in sorted_images)
# Calculate maximum text width needed
max_text_width = self._calculate_max_text_width(sorted_images)
# Cell width should accommodate both image and text
cell_width = max(max_image_size + self.margin * 2, max_text_width + self.margin * 2)
cell_height = max_image_size + self.label_height + self.margin * 2
# Create preview canvas with specified background color
canvas_width = cell_width * grid_cols
canvas_height = cell_height * grid_rows
canvas = Image.new('RGB', (canvas_width, canvas_height), self.background_color)
# Draw images and labels
for i, img_info in enumerate(sorted_images):
row = i // grid_cols
col = i % grid_cols
x = col * cell_width + self.margin
y = row * cell_height + self.margin
self._draw_image_cell(canvas, img_info, x, y, max_image_size, cell_width - self.margin * 2)
return canvas
def _calculate_max_text_width(self, images: List[Dict]) -> int:
"""Calculate the maximum text width needed for metadata display"""
# Try to load a font for measurement
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", self.font_size)
except:
try:
font = ImageFont.truetype("arial.ttf", self.font_size)
except:
font = ImageFont.load_default()
max_width = 0
# Create a temporary image for text measurement
temp_img = Image.new('RGB', (1, 1))
draw = ImageDraw.Draw(temp_img)
for img_info in images:
# Create metadata text lines
file_path = f"{img_info['path']}/{img_info['filename']}"
metadata_lines = [
f"Path: {file_path}",
f"Size: {img_info['size']}px",
f"State: {img_info['state']}",
f"Scale: {img_info['scale']:g}x",
f"File: {img_info['file_size']}B"
]
# Find the maximum width among all lines
for line in metadata_lines:
bbox = draw.textbbox((0, 0), line, font=font)
line_width = bbox[2] - bbox[0]
max_width = max(max_width, line_width)
# Add some padding for safety
return max_width + 20
def _draw_image_cell(self, canvas: Image.Image, img_info: Dict, x: int, y: int, cell_size: int, text_width: int):
"""Draw a single image cell with metadata"""
image = img_info['image']
# Resize image to fit cell while maintaining aspect ratio
image_size = min(cell_size, max(image.size))
if image.size[0] != image_size or image.size[1] != image_size:
image = image.resize((image_size, image_size), Image.Resampling.LANCZOS)
# Position image at left side of cell instead of center
img_x = x # Left align instead of centering
img_y = y + (cell_size - image.size[1]) // 2 # Still center vertically
# Paste image
if image.mode == 'RGBA':
canvas.paste(image, (img_x, img_y), image)
else:
canvas.paste(image, (img_x, img_y))
# Draw border around the icon to show its actual range
draw = ImageDraw.Draw(canvas)
# Calculate border coordinates (around the actual image)
border_x1 = img_x - 1
border_y1 = img_y - 1
border_x2 = img_x + image.size[0]
border_y2 = img_y + image.size[1]
# Choose border color based on background brightness
border_color = self._get_border_color()
# Draw a thin border around the icon
draw.rectangle([border_x1, border_y1, border_x2, border_y2],
outline=border_color, width=1)
# Draw padding indicator if padding exists
padding = img_info.get('layer_padding', 0)
if padding > 0:
# Calculate padding area in pixels
# Padding is typically a percentage or ratio, convert to pixels
padding_pixels = int(padding * image.size[0] / 100) if padding < 1 else int(padding)
# Calculate inner content area (after removing padding)
inner_x1 = img_x + padding_pixels
inner_y1 = img_y + padding_pixels
inner_x2 = img_x + image.size[0] - padding_pixels
inner_y2 = img_y + image.size[1] - padding_pixels
# Only draw if the inner area is valid
if inner_x2 > inner_x1 and inner_y2 > inner_y1:
# Draw dashed border around the content area (excluding padding)
self._draw_dashed_rectangle(draw, inner_x1, inner_y1, inner_x2, inner_y2, border_color)
# Try to load a font, fall back to default if not available
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", self.font_size)
except:
try:
font = ImageFont.truetype("arial.ttf", self.font_size)
except:
font = ImageFont.load_default()
# Create metadata text with file path as first line
file_path = f"{img_info['path']}/{img_info['filename']}"
metadata_lines = [
f"Path: {file_path}",
f"Size: {img_info['size']}px",
f"State: {img_info['state']}",
f"Scale: {img_info['scale']:g}x",
f"File: {img_info['file_size']}B"
]
# Add padding info if it exists
if padding > 0:
metadata_lines.append(f"Padding: {padding}")
# Process text lines with wrapping if needed
wrapped_lines = []
for line in metadata_lines:
wrapped_lines.extend(self._wrap_text(line, text_width, font, draw))
# 计算实际需要的文本高度
line_height = self.font_size + 2
total_text_height = len(wrapped_lines) * line_height
# 确保文本区域有足够的空间
text_start_y = y + cell_size + 5
available_height = self.label_height - 10 # 留一些边距
# 如果文本太多,调整行高或截断文本
if total_text_height > available_height:
# 调整行高以适应可用空间
line_height = max(self.font_size, available_height // len(wrapped_lines))
# Draw metadata text with contrasting color
text_y = text_start_y
for i, line in enumerate(wrapped_lines):
# 确保不超出可用区域
if text_y + line_height <= text_start_y + available_height:
draw.text((x, text_y), line, fill=self.text_color, font=font)
text_y += line_height
else:
# 如果空间不够,显示省略号
if i < len(wrapped_lines) - 1:
draw.text((x, text_y), "...", fill=self.text_color, font=font)
break
def _draw_dashed_rectangle(self, draw, x1, y1, x2, y2, color, dash_length=4, gap_length=2):
"""Draw a dashed rectangle border"""
# Draw top edge
self._draw_dashed_line(draw, x1, y1, x2, y1, color, dash_length, gap_length)
# Draw right edge
self._draw_dashed_line(draw, x2, y1, x2, y2, color, dash_length, gap_length)
# Draw bottom edge
self._draw_dashed_line(draw, x2, y2, x1, y2, color, dash_length, gap_length)
# Draw left edge
self._draw_dashed_line(draw, x1, y2, x1, y1, color, dash_length, gap_length)
def _draw_dashed_line(self, draw, x1, y1, x2, y2, color, dash_length=4, gap_length=2):
"""Draw a dashed line between two points"""
# Calculate line length and direction
dx = x2 - x1
dy = y2 - y1
line_length = (dx * dx + dy * dy) ** 0.5
if line_length == 0:
return
# Normalize direction vector
dx_norm = dx / line_length
dy_norm = dy / line_length
# Draw dashes
current_pos = 0
while current_pos < line_length:
# Calculate dash start and end positions
dash_start = current_pos
dash_end = min(current_pos + dash_length, line_length)
# Calculate actual coordinates
start_x = x1 + dx_norm * dash_start
start_y = y1 + dy_norm * dash_start
end_x = x1 + dx_norm * dash_end
end_y = y1 + dy_norm * dash_end
# Draw the dash
draw.line([(start_x, start_y), (end_x, end_y)], fill=color, width=1)
# Move to next dash position
current_pos += dash_length + gap_length
def _get_border_color(self):
"""Get border color that follows text color for better visual consistency"""
# Use the same color as text for border, but with reduced opacity effect
# This creates a cohesive visual appearance where border and text match
# Get the current text color
text_r, text_g, text_b = self.text_color
# Create a slightly lighter/darker version of text color for border
# This maintains color consistency while ensuring the border is visible
if text_r + text_g + text_b > 384: # Light text (white-ish)
# For light text, make border slightly darker
border_r = max(0, text_r - 64)
border_g = max(0, text_g - 64)
border_b = max(0, text_b - 64)
else: # Dark text (black-ish)
# For dark text, make border slightly lighter
border_r = min(255, text_r + 64)
border_g = min(255, text_g + 64)
border_b = min(255, text_b + 64)
return (border_r, border_g, border_b)
def _wrap_text(self, text: str, max_width: int, font, draw) -> List[str]:
"""Wrap text to fit within the specified width"""
# Check if text fits in one line
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
if text_width <= max_width:
return [text]
# Text is too long, need to wrap
words = text.split(' ')
lines = []
current_line = ""
for word in words:
test_line = current_line + (" " if current_line else "") + word
bbox = draw.textbbox((0, 0), test_line, font=font)
test_width = bbox[2] - bbox[0]
if test_width <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = word
else:
# Single word is too long, truncate it
truncated_word = word
while len(truncated_word) > 3:
test_word = truncated_word[:-3] + "..."
bbox = draw.textbbox((0, 0), test_word, font=font)
test_width = bbox[2] - bbox[0]
if test_width <= max_width:
lines.append(test_word)
break
truncated_word = truncated_word[:-1]
else:
lines.append("...")
current_line = ""
if current_line:
lines.append(current_line)
return lines
def _create_empty_preview(self, background_color=None) -> Image.Image:
"""Create an empty preview image"""
bg_color = background_color if background_color is not None else self.background_color
text_color = self._get_contrasting_text_color(bg_color)
canvas = Image.new('RGB', (400, 200), bg_color)
draw = ImageDraw.Draw(canvas)
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16)
except:
font = ImageFont.load_default()
text = "No DCI images found"
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
x = (canvas.width - text_width) // 2
y = (canvas.height - text_height) // 2
draw.text((x, y), text, fill=text_color, font=font)
return canvas
def create_metadata_summary(self, images: List[Dict]) -> Dict:
"""Create a summary of metadata from all images"""
if not images:
return {}
summary = {
'total_images': len(images),
'sizes': set(),
'states': set(),
'tones': set(),
'scales': set(),
'formats': set(),
'total_file_size': 0
}
for img in images:
summary['sizes'].add(img['size'])
summary['states'].add(img['state'])
summary['tones'].add(img['tone'])
summary['scales'].add(img['scale'])
summary['formats'].add(img['format'])
summary['total_file_size'] += img['file_size']
# Convert sets to sorted lists
summary['sizes'] = sorted(list(summary['sizes']))
summary['states'] = sorted(list(summary['states']))
summary['tones'] = sorted(list(summary['tones']))
summary['scales'] = sorted(list(summary['scales']))
summary['formats'] = sorted(list(summary['formats']))
return summary