1. Added "universal" tone type to support icons that don't differentiate between light and dark themes. 2. When "universal" is selected, the image is stored in the "light" directory, and a symbolic link is created in the "dark" directory, pointing to the "light" directory's image. 3. The symlink uses a relative path to comply with the DCI specification. 4. Updated node defaults to use universal tone. 5. Modified DCI reader and writer to handle universal tone symlinks. 6. Modified `format_dci_path` to support universal tone type and keep naming consistent. 7. Added helper functions for creating and resolving symlinks. 8. This change addresses the need to support generic application icons that are not specifically designed for light or dark modes, allowing for a more streamlined workflow when exporting icons to the DCI format. Log: Added support for a new universal tone type for icons. Influence: 1. Test creating icons with the universal tone type. 2. Verify that symbolic links are created correctly in the "dark" directory. 3. Confirm that the application correctly displays the icon in both light and dark themes. 4. Test reading DCI files with universal tone icons. 5. Ensure backward compatibility with existing light and dark tone types. 6. Check file size and disk space usage when using universal tone, considering the overhead of symlinks. 7. Verify relative paths in symlinks are correct according to DCI spec. 8. Test the loading speed of DCI files with many symlinks. feat: 支持通用色调类型 1. 添加了“通用”色调类型,以支持不区分浅色和深色主题的图标。 2. 选择“通用”时,图像存储在“浅色”目录中,并在“深色”目录中创建一个指向“浅 色”目录中图像的符号链接。 3. 符号链接使用相对路径,以符合 DCI 规范。 4. 更新了节点默认值以使用通用色调。 5. 修改了 DCI 读取器和写入器以处理通用色调符号链接。 6. 修改了`format_dci_path` 以支持通用色调类型并保持命名一致。 7. 添加了用于创建和解析符号链接的辅助函数。 8. 此更改解决了支持非专门为浅色或深色模式设计的通用应用程序图标的需求, 从而可以在将图标导出为 DCI 格式时简化工作流程。 Log: 添加了对图标的新通用色调类型的支持。 Influence: 1. 测试创建具有通用色调类型的图标。 2. 验证符号链接是否在“深色”目录中正确创建。 3. 确认应用程序在浅色和深色主题中均正确显示该图标。 4. 测试读取带有通用色调图标的 DCI 文件。 5. 确保与现有的浅色和深色调类型的向后兼容性。 6. 检查使用通用色调时的文件大小和磁盘空间使用情况,考虑到符号链接的 开销。 7. 验证符号链接中的相对路径是否符合 DCI 规范。 8. 测试具有大量符号链接的 DCI 文件的加载速度。
368 lines
15 KiB
Python
368 lines
15 KiB
Python
import struct
|
|
import os
|
|
from typing import List, Dict, Optional, Union
|
|
from io import BytesIO
|
|
from PIL import Image
|
|
import tempfile
|
|
import re
|
|
|
|
|
|
class DCIFile:
|
|
"""DCI (DSG Combined Icons) file format implementation"""
|
|
|
|
MAGIC = b'DCI\x00' # Magic header
|
|
VERSION = 1
|
|
|
|
# File types
|
|
FILE_TYPE_RESERVED = 0
|
|
FILE_TYPE_FILE = 1
|
|
FILE_TYPE_DIRECTORY = 2
|
|
FILE_TYPE_LINK = 3
|
|
|
|
def __init__(self):
|
|
self.files = []
|
|
|
|
def add_file(self, name: str, content: bytes, file_type: int = FILE_TYPE_FILE):
|
|
"""Add a file to the DCI archive"""
|
|
if len(name.encode('utf-8')) > 62: # 63 bytes - 1 for null terminator
|
|
raise ValueError(f"File name too long: {name}")
|
|
|
|
if '/' in name:
|
|
raise ValueError(f"File name cannot contain '/': {name}")
|
|
|
|
self.files.append({
|
|
'name': name,
|
|
'content': content,
|
|
'type': file_type
|
|
})
|
|
|
|
def add_directory(self, name: str, files: List[Dict]):
|
|
"""Add a directory with files to the DCI archive"""
|
|
# Create directory content by packing all files in it
|
|
dir_content = BytesIO()
|
|
|
|
# Sort files by name (natural sort)
|
|
sorted_files = sorted(files, key=lambda x: self._natural_sort_key(x['name']))
|
|
|
|
for file_info in sorted_files:
|
|
# Write file metadata
|
|
file_name_bytes = file_info['name'].encode('utf-8')
|
|
if len(file_name_bytes) > 62:
|
|
raise ValueError(f"File name too long: {file_info['name']}")
|
|
|
|
# File type (1 byte)
|
|
dir_content.write(struct.pack('<B', file_info.get('type', self.FILE_TYPE_FILE)))
|
|
|
|
# File name (63 bytes, null-terminated)
|
|
name_padded = file_name_bytes + b'\x00' * (63 - len(file_name_bytes))
|
|
dir_content.write(name_padded)
|
|
|
|
# Content size (8 bytes)
|
|
content = file_info['content']
|
|
dir_content.write(struct.pack('<Q', len(content)))
|
|
|
|
# Content
|
|
dir_content.write(content)
|
|
|
|
self.add_file(name, dir_content.getvalue(), self.FILE_TYPE_DIRECTORY)
|
|
|
|
def _natural_sort_key(self, text: str):
|
|
"""Natural sorting key for filenames"""
|
|
def convert(text):
|
|
return int(text) if text.isdigit() else text.lower()
|
|
return [convert(c) for c in re.split('([0-9]+)', text)]
|
|
|
|
def write(self, output_path: str):
|
|
"""Write DCI file to disk"""
|
|
binary_data = self.to_binary()
|
|
with open(output_path, 'wb') as f:
|
|
f.write(binary_data)
|
|
|
|
def to_binary(self) -> bytes:
|
|
"""Generate DCI file as binary data"""
|
|
output = BytesIO()
|
|
|
|
# Write header
|
|
output.write(self.MAGIC)
|
|
output.write(struct.pack('<B', self.VERSION))
|
|
|
|
# Sort files by name
|
|
sorted_files = sorted(self.files, key=lambda x: self._natural_sort_key(x['name']))
|
|
|
|
# Write file count (3 bytes)
|
|
file_count = len(sorted_files)
|
|
output.write(struct.pack('<I', file_count)[:3]) # Take only first 3 bytes
|
|
|
|
# Write file metadata and content
|
|
for file_info in sorted_files:
|
|
# File type (1 byte)
|
|
output.write(struct.pack('<B', file_info['type']))
|
|
|
|
# File name (63 bytes, null-terminated)
|
|
file_name_bytes = file_info['name'].encode('utf-8')
|
|
name_padded = file_name_bytes + b'\x00' * (63 - len(file_name_bytes))
|
|
output.write(name_padded)
|
|
|
|
# Content size (8 bytes)
|
|
content = file_info['content']
|
|
output.write(struct.pack('<Q', len(content)))
|
|
|
|
# Content
|
|
output.write(content)
|
|
|
|
return output.getvalue()
|
|
|
|
|
|
class DCIIconBuilder:
|
|
"""Builder for DCI icon files following the icon specification"""
|
|
|
|
ICON_STATES = ['normal', 'disabled', 'hover', 'pressed']
|
|
TONE_TYPES = ['universal', 'light', 'dark']
|
|
SUPPORTED_FORMATS = ['png', 'jpg', 'webp']
|
|
|
|
def __init__(self):
|
|
self.dci = DCIFile()
|
|
self.directory_structure = {} # Track directory structure
|
|
|
|
def add_icon_image(self, image: Image.Image, size: int, state: str = 'normal',
|
|
tone: str = 'universal', scale: float = 1.0, format: str = 'webp', quality: int = 90,
|
|
webp_lossless: bool = False, webp_alpha_quality: int = 100, png_compress_level: int = 6):
|
|
"""Add an icon image for specific state, tone, and scale"""
|
|
|
|
if state not in self.ICON_STATES:
|
|
raise ValueError(f"Invalid state: {state}. Must be one of {self.ICON_STATES}")
|
|
|
|
if tone not in self.TONE_TYPES:
|
|
raise ValueError(f"Invalid tone: {tone}. Must be one of {self.TONE_TYPES}")
|
|
|
|
if format not in self.SUPPORTED_FORMATS:
|
|
raise ValueError(f"Invalid format: {format}. Must be one of {self.SUPPORTED_FORMATS}")
|
|
|
|
# Calculate actual size with scale
|
|
actual_size = int(size * scale)
|
|
|
|
# Resize image to target size
|
|
resized_image = image.resize((actual_size, actual_size), Image.Resampling.LANCZOS)
|
|
|
|
# Convert to bytes
|
|
img_bytes = BytesIO()
|
|
if format == 'webp':
|
|
# WebP advanced settings
|
|
if webp_lossless:
|
|
# Lossless WebP
|
|
resized_image.save(img_bytes, format='WEBP', lossless=True)
|
|
else:
|
|
# Standard lossy WebP with alpha quality control
|
|
if resized_image.mode == 'RGBA':
|
|
resized_image.save(img_bytes, format='WEBP', quality=quality, alpha_quality=webp_alpha_quality)
|
|
else:
|
|
resized_image.save(img_bytes, format='WEBP', quality=quality)
|
|
elif format == 'png':
|
|
# PNG with compression level control
|
|
resized_image.save(img_bytes, format='PNG', compress_level=png_compress_level)
|
|
elif format == 'jpg':
|
|
# Convert to RGB if necessary for JPEG
|
|
if resized_image.mode in ('RGBA', 'LA', 'P'):
|
|
rgb_image = Image.new('RGB', resized_image.size, (255, 255, 255))
|
|
if resized_image.mode == 'P':
|
|
resized_image = resized_image.convert('RGBA')
|
|
rgb_image.paste(resized_image, mask=resized_image.split()[-1] if resized_image.mode in ('RGBA', 'LA') else None)
|
|
resized_image = rgb_image
|
|
resized_image.save(img_bytes, format='JPEG', quality=quality)
|
|
|
|
img_content = img_bytes.getvalue()
|
|
|
|
# Handle universal tone type
|
|
if tone == 'universal':
|
|
# Store image in light directory
|
|
self._add_icon_image_internal(image, img_content, size, state, 'light', scale, format)
|
|
# Create symlink in dark directory
|
|
self._add_icon_symlink(size, state, 'light', 'dark', scale, format)
|
|
else:
|
|
# Normal processing for light/dark tones
|
|
self._add_icon_image_internal(image, img_content, size, state, tone, scale, format)
|
|
|
|
def _add_icon_image_internal(self, image: Image.Image, img_content: bytes, size: int, state: str,
|
|
tone: str, scale: float, format: str):
|
|
"""Internal method to add icon image to structure"""
|
|
# Create directory structure: size/state.tone/scale/1.0p.-1.0_0_0_0_0_0_0.format
|
|
size_dir = str(size)
|
|
state_tone_dir = f"{state}.{tone}"
|
|
# Format scale to remove unnecessary decimal places
|
|
scale_dir = f"{scale:g}"
|
|
|
|
# Layer file: priority.padding.palette.hue.saturation.brightness.red.green.blue.alpha.format
|
|
layer_filename = f"1.0p.-1.0_0_0_0_0_0_0.{format}"
|
|
|
|
# Build nested directory structure
|
|
self._add_to_structure(size_dir, state_tone_dir, scale_dir, layer_filename, img_content)
|
|
|
|
def _add_icon_symlink(self, size: int, state: str, source_tone: str, target_tone: str, scale: float, format: str):
|
|
"""Add a symlink from target_tone directory to source_tone directory"""
|
|
size_dir = str(size)
|
|
source_state_tone_dir = f"{state}.{source_tone}"
|
|
target_state_tone_dir = f"{state}.{target_tone}"
|
|
scale_dir = f"{scale:g}"
|
|
layer_filename = f"1.0p.-1.0_0_0_0_0_0_0.{format}"
|
|
|
|
# Create symlink path: ../../state.source_tone/scale/filename
|
|
# Need to go up two levels: from scale_dir to state_tone_dir, then to size_dir
|
|
symlink_target = f"../../{source_state_tone_dir}/{scale_dir}/{layer_filename}"
|
|
|
|
# Add symlink to target directory structure
|
|
self._add_symlink_to_structure(size_dir, target_state_tone_dir, scale_dir, layer_filename, symlink_target)
|
|
|
|
def _add_to_structure(self, size_dir: str, state_tone_dir: str,
|
|
scale_dir: str, filename: str, content: bytes):
|
|
"""Add file to the directory structure"""
|
|
|
|
# Initialize structure if needed
|
|
if size_dir not in self.directory_structure:
|
|
self.directory_structure[size_dir] = {}
|
|
|
|
if state_tone_dir not in self.directory_structure[size_dir]:
|
|
self.directory_structure[size_dir][state_tone_dir] = {}
|
|
|
|
if scale_dir not in self.directory_structure[size_dir][state_tone_dir]:
|
|
self.directory_structure[size_dir][state_tone_dir][scale_dir] = {}
|
|
|
|
# Add the file
|
|
self.directory_structure[size_dir][state_tone_dir][scale_dir][filename] = {
|
|
'type': 'file',
|
|
'content': content
|
|
}
|
|
|
|
def _add_symlink_to_structure(self, size_dir: str, state_tone_dir: str,
|
|
scale_dir: str, filename: str, target_path: str):
|
|
"""Add symlink to the directory structure"""
|
|
|
|
# Initialize structure if needed
|
|
if size_dir not in self.directory_structure:
|
|
self.directory_structure[size_dir] = {}
|
|
|
|
if state_tone_dir not in self.directory_structure[size_dir]:
|
|
self.directory_structure[size_dir][state_tone_dir] = {}
|
|
|
|
if scale_dir not in self.directory_structure[size_dir][state_tone_dir]:
|
|
self.directory_structure[size_dir][state_tone_dir][scale_dir] = {}
|
|
|
|
# Add the symlink
|
|
self.directory_structure[size_dir][state_tone_dir][scale_dir][filename] = {
|
|
'type': 'symlink',
|
|
'target': target_path
|
|
}
|
|
|
|
def build(self, output_path: str):
|
|
"""Build and write the DCI file"""
|
|
binary_data = self.to_binary()
|
|
with open(output_path, 'wb') as f:
|
|
f.write(binary_data)
|
|
|
|
def to_binary(self) -> bytes:
|
|
"""Build and return the DCI file as binary data"""
|
|
# Convert directory structure to DCI format
|
|
for size_dir, size_content in self.directory_structure.items():
|
|
state_tone_dirs = []
|
|
|
|
for state_tone_dir, state_tone_content in size_content.items():
|
|
scale_dirs = []
|
|
|
|
for scale_dir, scale_content in state_tone_content.items():
|
|
# Create files for this scale directory
|
|
scale_files = []
|
|
for filename, file_info in scale_content.items():
|
|
if isinstance(file_info, dict):
|
|
if file_info['type'] == 'file':
|
|
scale_files.append({
|
|
'name': filename,
|
|
'content': file_info['content'],
|
|
'type': DCIFile.FILE_TYPE_FILE
|
|
})
|
|
elif file_info['type'] == 'symlink':
|
|
# Create symlink content (target path as UTF-8 bytes)
|
|
symlink_content = file_info['target'].encode('utf-8')
|
|
scale_files.append({
|
|
'name': filename,
|
|
'content': symlink_content,
|
|
'type': DCIFile.FILE_TYPE_LINK
|
|
})
|
|
else:
|
|
# Backward compatibility: treat as file content
|
|
scale_files.append({
|
|
'name': filename,
|
|
'content': file_info,
|
|
'type': DCIFile.FILE_TYPE_FILE
|
|
})
|
|
|
|
# Create scale directory
|
|
scale_dir_content = self._create_directory_content(scale_files)
|
|
scale_dirs.append({
|
|
'name': scale_dir,
|
|
'content': scale_dir_content,
|
|
'type': DCIFile.FILE_TYPE_DIRECTORY
|
|
})
|
|
|
|
# Create state.tone directory
|
|
state_tone_dir_content = self._create_directory_content(scale_dirs)
|
|
state_tone_dirs.append({
|
|
'name': state_tone_dir,
|
|
'content': state_tone_dir_content,
|
|
'type': DCIFile.FILE_TYPE_DIRECTORY
|
|
})
|
|
|
|
# Add size directory to DCI
|
|
self.dci.add_directory(size_dir, state_tone_dirs)
|
|
|
|
# Return the DCI file as binary data
|
|
return self.dci.to_binary()
|
|
|
|
def _create_directory_content(self, files: List[Dict]) -> bytes:
|
|
"""Create directory content from file list"""
|
|
dir_content = BytesIO()
|
|
|
|
# Sort files by name
|
|
sorted_files = sorted(files, key=lambda x: self.dci._natural_sort_key(x['name']))
|
|
|
|
for file_info in sorted_files:
|
|
# File type (1 byte)
|
|
dir_content.write(struct.pack('<B', file_info.get('type', DCIFile.FILE_TYPE_FILE)))
|
|
|
|
# File name (63 bytes, null-terminated)
|
|
file_name_bytes = file_info['name'].encode('utf-8')
|
|
name_padded = file_name_bytes + b'\x00' * (63 - len(file_name_bytes))
|
|
dir_content.write(name_padded)
|
|
|
|
# Content size (8 bytes)
|
|
content = file_info['content']
|
|
dir_content.write(struct.pack('<Q', len(content)))
|
|
|
|
# Content
|
|
dir_content.write(content)
|
|
|
|
return dir_content.getvalue()
|
|
|
|
|
|
def create_dci_icon(image: Image.Image, output_path: str, size: int = 256,
|
|
states: List[str] = None, tones: List[str] = None,
|
|
scales: List[float] = None, format: str = 'webp', quality: int = 90,
|
|
webp_lossless: bool = False, webp_alpha_quality: int = 100, png_compress_level: int = 6):
|
|
"""Create a DCI icon file from an image"""
|
|
|
|
if states is None:
|
|
states = ['normal']
|
|
if tones is None:
|
|
tones = ['universal']
|
|
if scales is None:
|
|
scales = [1.0, 1.25, 1.5, 2.0]
|
|
|
|
builder = DCIIconBuilder()
|
|
|
|
# Add images for all combinations of states, tones, and scales
|
|
for state in states:
|
|
for tone in tones:
|
|
for scale in scales:
|
|
builder.add_icon_image(image, size, state, tone, scale, format, quality,
|
|
webp_lossless, webp_alpha_quality, png_compress_level)
|
|
|
|
builder.build(output_path)
|