Files
zccrs-comfyui-dci/py/dci_format.py
T
JiDe Zhang 1db44183d1 feat: Add decimal scale factor support for DCI images
1. Modify DCIImage node scale parameter from INT to FLOAT type
2. Support decimal scale range from 0.1 to 10.0 with 0.1 step increment
3. Update DCIIconBuilder.add_icon_image method to handle float scale values
4. Improve DCI reader to correctly parse and display decimal scale factors
5. Update create_dci_icon function default scales to include decimal examples
6. Add comprehensive documentation for decimal scaling functionality
7. Include test cases to verify decimal scale calculation accuracy

This enhancement enables fine-grained scaling control for modern high-DPI
displays, supporting scales like 1.25x, 1.5x which are commonly used in
125% and 150% display scaling scenarios. The implementation maintains
backward compatibility with integer scales while providing smoother
scaling transitions for better visual quality.

feat: 为DCI图像添加小数缩放因子支持

1. 修改DCIImage节点的scale参数从INT类型改为FLOAT类型
2. 支持0.1到10.0范围的小数缩放,步长为0.1
3. 更新DCIIconBuilder.add_icon_image方法以处理浮点缩放值
4. 改进DCI读取器以正确解析和显示小数缩放因子
5. 更新create_dci_icon函数默认缩放值包含小数示例
6. 添加小数缩放功能的全面文档说明
7. 包含测试用例以验证小数缩放计算的准确性

此增强功能为现代高DPI显示器提供精细的缩放控制,支持1.25x、1.5x等
常用于125%和150%显示缩放场景的缩放比例。实现保持与整数缩放的向后
兼容性,同时提供更平滑的缩放过渡以获得更好的视觉质量.
2025-06-09 16:48:44 +08:00

287 lines
10 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 = ['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 = 'dark', scale: float = 1.0, format: str = 'webp'):
"""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':
resized_image.save(img_bytes, format='WEBP', quality=90)
elif format == 'png':
resized_image.save(img_bytes, format='PNG')
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=90)
img_content = img_bytes.getvalue()
# Create directory structure: size/state.tone/scale/1.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.0.0.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_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] = content
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_content in scale_content.items():
scale_files.append({
'name': filename,
'content': file_content,
'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'):
"""Create a DCI icon file from an image"""
if states is None:
states = ['normal']
if tones is None:
tones = ['light']
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)
builder.build(output_path)