Video Tutorial
Documentación
Cómo instalar el convertidor de imágenes gratis en tu PC
Esta herramienta es completamente gratuita y funciona en Windows, macOS y Linux. No necesitas saber programar para instalarla. A continuación te explicamos cómo hacerlo paso a paso según tu sistema operativo.
¿Qué necesito antes de instalar?
- Tener Python 3.10 o superior instalado en tu computadora
- Conexión a internet durante la instalación (solo la primera vez)
- El archivo del programa descargado desde este sitio
¿No tienes Python? No te preocupes — es gratuito y fácil de instalar. Descárgalo desde python.org. Durante la instalación, asegúrate de marcar la opción "Add Python to PATH".
🪟 Instalación en Windows — La forma más fácil
En Windows solo necesitas hacer doble clic en un archivo.
El instalador automático instalador.bat hace todo el trabajo por ti:
verifica Python, actualiza las herramientas internas e instala todo lo necesario.
Tambien puedes instalarlo mediante requeriments.txt como se explica en el video usando:py -m pip install -r requirements.txt
Pasos para instalar en Windows
- Descarga y descomprime el archivo del programa en una carpeta de tu elección.
- Abre la carpeta del programa en el Explorador de archivos.
- Haz doble clic sobre el archivo
instalador.bat. - Se abrirá una ventana negra (terminal). Espera a que termine — verás mensajes de progreso.
- Cuando aparezca el mensaje de instalación completada, presiona cualquier tecla para cerrar.
- Ahora puedes ejecutar el programa haciendo doble clic en
ImageConverter.py, o abriendo una terminal en esa carpeta y escribiendo:
py ImageConverter.py
✅ ¿Qué instala el instalador.bat? Instala automáticamente todo lo necesario:
Pillow, customtkinter, piexif, tkinterdnd2
y soporte opcional para fotos de iPhone (HEIC/HEIF).
¿La ventana negra se cierra sola sin terminar?
- Haz clic derecho sobre
instalador.baty selecciona "Ejecutar como administrador". - Asegúrate de haber marcado "Add Python to PATH" al instalar Python.
🍎 Instalación en macOS
En Mac usamos el archivo requirements.txt incluido en el programa.
Este archivo le dice a Python exactamente qué herramientas necesita instalar.
Pasos para instalar en macOS
- Abre la aplicación Terminal (atajo: Cmd + Espacio → escribe "Terminal" → Enter).
- Comprueba que tienes Python 3.10 o superior instalado:
python3 --version
Si no tienes Python o la versión es muy antigua, instálalo desde python.org. También puedes usar Homebrew si ya lo tienes instalado:
brew install python
- En la terminal, navega hasta la carpeta donde descargaste el programa:
cd ~/Downloads/ImageConverterPro
- Instala todas las dependencias con este comando:
pip3 install -r requirements.txt
- Cuando termine, ejecuta el programa:
python3 ImageConverter.py
Nota: El soporte para fotos HEIC de iPhone puede requerir pasos adicionales en Mac. Si falla esa parte, el programa seguirá funcionando perfectamente con todos los demás formatos.
🐧 Instalación en Linux
En Linux el proceso es muy similar al de macOS. Solo necesitas abrir una terminal
e instalar las dependencias con el archivo requirements.txt.
Pasos para instalar en Linux
- Abre una terminal.
- Primero, asegúrate de tener Python y Tkinter instalados. En Ubuntu/Debian:
sudo apt update && sudo apt install python3 python3-pip python3-tk
En Fedora/RHEL/CentOS:
sudo dnf install python3 python3-pip python3-tkinter
- Navega a la carpeta del programa:
cd ~/Descargas/ImageConverterPro
- Instala las dependencias:
pip3 install -r requirements.txt
- Ejecuta el programa:
python3 ImageConverter.py
Importante para Linux: Si la interfaz gráfica no se abre, es casi siempre por falta
de python3-tk. Instálalo con el comando de tu distribución y vuelve a intentarlo.
✅ ¿Cómo sé que funcionó bien?
Si la instalación fue exitosa, al ejecutar el programa se abrirá una ventana con interfaz gráfica. Desde ahí podrás arrastrar imágenes, elegir el formato de salida y convertirlas con un clic.
Formatos que puedes convertir
- Fotos de iPhone:
HEIC → JPGoHEIC → PNG - Imágenes web:
WEBP → JPGoWEBP → PNG - Alta calidad:
TIFF → JPGoPNG → WEBP - Íconos:
PNG → ICO - Y muchos más: BMP, GIF, PCX, TGA, PPM
¿Algo no funciona? Revisa esto primero
- ✅ Python está instalado y en el PATH del sistema
- ✅ Usaste el instalador o ejecutaste
pip install -r requirements.txt - ✅ En Linux, tienes instalado
python3-tk - ✅ En Windows, ejecutaste el
.batcomo Administrador si hubo errores
Librerías Necesarias
py -m pip install Pillow>=10.0.0
py -m pip install customtkinter>=5.2.0
py -m piexif>=1.1.3
py -m pip install tkinterdnd2>=0.5.0
py -m pip install pillow-heif>=0.13.0
Código Fuente Principal
#!/usr/bin/env python3
"""
ImageConverter Pro - Convertidor Universal de Imágenes
========================================================
Una aplicación moderna de escritorio para convertir imágenes entre múltiples formatos.
Interfaz elegante tipo Adobe Lightroom, procesamiento paralelo y máxima calidad.
Características:
- Soporte para 15+ formatos de imagen (PNG, JPG, WEBP, GIF, TIFF, etc.)
- Conversión de archivos individuales y batch
- Procesamiento paralelo de múltiples imágenes
- Interfaz moderna con tema oscuro
- Drag & Drop de archivos
- Vista previa de imagen
- Información de resolución y metadatos EXIF
- Barra de progreso y logging detallado
- Opciones avanzadas (calidad, redimensionamiento, etc.)
- Soporte para animaciones GIF
Author: Senior Python Developer
Version: 1.0.0
Python: 3.10+
"""
import sys
import os
from pathlib import Path
from typing import Optional, Dict, List, Tuple, Any
import threading
import logging
from logging.handlers import RotatingFileHandler
from dataclasses import dataclass
from enum import Enum
from datetime import datetime
import json
from PIL import Image, ImageOps, ImageEnhance
import customtkinter as ctk
from tkinter import filedialog, messagebox, scrolledtext
import tkinter as tk
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
import traceback
import pillow_heif
pillow_heif.register_heif_opener()
HEIF_SUPPORT = True
AVIF_SUPPORT = False
import piexif
EXIF_SUPPORT = True
# ============================================================================
# CONFIGURACIÓN CENTRALIZADA
# ============================================================================
class Config:
"""Configuración centralizada de la aplicación."""
# Información de la aplicación
APP_NAME = "ImageConverter Pro"
APP_VERSION = "1.0.0"
APP_DESCRIPTION = "Convertidor universal de imágenes con interfaz moderna"
# Directorios
BASE_DIR = Path(__file__).parent.resolve()
LOGS_DIR = BASE_DIR / "logs"
TEMP_DIR = Path(os.getenv("TEMP", ".temp"))
# Crear directorios si no existen
LOGS_DIR.mkdir(parents=True, exist_ok=True)
TEMP_DIR.mkdir(parents=True, exist_ok=True)
# Logging
LOG_FILE = str(LOGS_DIR / "imageconverter.log")
LOG_LEVEL = logging.INFO
# Configuración de calidad (máxima por defecto)
JPEG_QUALITY = 95
JPEG_OPTIMIZE = True
JPEG_PROGRESSIVE = True
PNG_COMPRESS_LEVEL = 9
PNG_OPTIMIZE = True
WEBP_QUALITY = 95
WEBP_METHOD = 6
WEBP_EXACT = True
TIFF_COMPRESSION = 'lzw'
RESAMPLE_FILTER = Image.Resampling.LANCZOS
# Procesamiento
MAX_WORKERS = None # None = número de CPUs
CONVERSION_TIMEOUT = 60
PREVIEW_MAX_SIZE = (400, 400)
# Paleta de colores - Tema oscuro profesional
class Colors:
BG_PRIMARY = "#1a1a1a"
BG_SECONDARY = "#2d2d2d"
BG_TERTIARY = "#3a3a3a"
BG_HOVER = "#404040"
BG_SELECTED = "#4a4a4a"
ACCENT_PRIMARY = "#0d8fd9"
ACCENT_SECONDARY = "#17a2b8"
TEXT_PRIMARY = "#e8e8e8"
TEXT_SECONDARY = "#a0a0a0"
TEXT_MUTED = "#7a7a7a"
SUCCESS = "#28a745"
WARNING = "#ffc107"
ERROR = "#dc3545"
INFO = "#0d8fd9"
BORDER = "#404040"
# Interfaz
WINDOW_WIDTH = 1400
WINDOW_HEIGHT = 900
WINDOW_MIN_WIDTH = 1000
WINDOW_MIN_HEIGHT = 700
PADDING_LARGE = 20
PADDING_MEDIUM = 12
PADDING_SMALL = 8
PADDING_TINY = 4
CORNER_RADIUS = 8
FONT_FAMILY = "Segoe UI" if os.name == "nt" else "Ubuntu"
FONT_SIZE_TITLE = 20
FONT_SIZE_HEADING = 14
FONT_SIZE_NORMAL = 11
FONT_SIZE_SMALL = 10
FONT_SIZE_TINY = 9
# Mejora de imagen
ENHANCE_CONTRAST = True
CONTRAST_FACTOR = 1.1
ENHANCE_SHARPNESS = True
SHARPNESS_FACTOR = 1.2
# Formatos soportados
SUPPORTED_FORMATS = {
'PNG': 'image/png',
'JPG': 'image/jpeg',
'JPEG': 'image/jpeg',
'BMP': 'image/bmp',
'GIF': 'image/gif',
'TIFF': 'image/tiff',
'TIF': 'image/tiff',
'WEBP': 'image/webp',
'ICO': 'image/x-icon',
'PPM': 'image/x-portable-pixmap',
'TGA': 'image/x-tga',
'PCX': 'image/x-pcx',
}
if HEIF_SUPPORT:
SUPPORTED_FORMATS.update({'HEIF': 'image/heif', 'HEIC': 'image/heic'})
if AVIF_SUPPORT:
SUPPORTED_FORMATS.update({'AVIF': 'image/avif'})
# Formatos con características especiales
FORMATS_WITH_TRANSPARENCY = ['PNG', 'GIF', 'WEBP', 'ICO', 'TIFF']
FORMATS_WITH_ANIMATION = ['GIF', 'WEBP']
# ============================================================================
# SISTEMA DE LOGGING
# ============================================================================
def setup_logger(name: str) -> logging.Logger:
"""
Configura el logger de la aplicación.
Args:
name: Nombre del logger
Returns:
Logger configurado
"""
logger = logging.getLogger(name)
logger.setLevel(Config.LOG_LEVEL)
# Handler de archivo con rotación
file_handler = RotatingFileHandler(
Config.LOG_FILE,
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5
)
file_handler.setLevel(Config.LOG_LEVEL)
# Formato
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
file_handler.setFormatter(formatter)
# Evitar handlers duplicados
if not logger.handlers:
logger.addHandler(file_handler)
return logger
logger = setup_logger(__name__)
# Logger separado solo para conversiones
conversion_logger = logging.getLogger('conversions')
conversion_logger.setLevel(logging.INFO)
# Handler para conversiones
conversion_handler = RotatingFileHandler(
Config.LOG_FILE,
maxBytes=10 * 1024 * 1024,
backupCount=5
)
conversion_handler.setLevel(logging.INFO)
conversion_formatter = logging.Formatter(
'%(asctime)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
conversion_handler.setFormatter(conversion_formatter)
if not conversion_logger.handlers:
conversion_logger.addHandler(conversion_handler)
def reset_conversion_log() -> None:
"""Borra el contenido del archivo de registros de conversiones."""
try:
# Cerrar el handler para liberar el archivo
for handler in list(conversion_logger.handlers):
conversion_logger.removeHandler(handler)
handler.close()
# Vaciar el archivo
with open(Config.LOG_FILE, 'w', encoding='utf-8') as log_file:
log_file.write('')
# Recrear el handler
new_handler = RotatingFileHandler(
Config.LOG_FILE,
maxBytes=10 * 1024 * 1024,
backupCount=5
)
new_handler.setLevel(logging.INFO)
new_handler.setFormatter(conversion_formatter)
conversion_logger.addHandler(new_handler)
except Exception as exc:
logger.warning(f"No se pudo limpiar el registro: {exc}")
def parse_conversion_log_entry(log_line: str) -> Optional[Tuple[Path, str, str]]:
"""Parses a conversion log line and returns (image_path, input_format, output_format)."""
if not log_line or not isinstance(log_line, str):
return None
line = log_line.strip()
if ' - INFO - ' in line:
line = line.split(' - INFO - ', 1)[1].strip()
elif ' - ERROR - ' in line:
line = line.split(' - ERROR - ', 1)[1].strip()
if not line or '|' not in line:
return None
if '->' in line:
path_text, formats_text = line.split('|', 1)
if '->' not in formats_text:
return None
input_fmt, output_fmt = [part.strip() for part in formats_text.split('->', 1)]
if not input_fmt or not output_fmt:
return None
return Path(path_text.strip()), input_fmt.upper(), output_fmt.upper()
parts = [part.strip() for part in line.split('|')]
if len(parts) != 3:
return None
path_text, input_fmt, output_fmt = parts
if not path_text or not input_fmt or not output_fmt:
return None
return Path(path_text), input_fmt.upper(), output_fmt.upper()
def reset_conversion_log() -> None:
"""Borra el contenido del archivo de registros de conversiones."""
try:
with open(Config.LOG_FILE, 'w', encoding='utf-8') as log_file:
log_file.write('')
except Exception as exc:
logger.warning(f"No se pudo limpiar el registro: {exc}")
# ============================================================================
# ENUMERACIONES Y CLASES DE DATOS
# ============================================================================
class ConversionMode(Enum):
"""Modos de conversión disponibles."""
LOSSLESS = "lossless"
LOSSY = "lossy"
class TransparencyMode(Enum):
"""Modos de manejo de transparencia."""
PRESERVE = "preserve"
REMOVE = "remove"
WHITE_BG = "white_bg"
BLACK_BG = "black_bg"
@dataclass
class ConversionResult:
"""Resultado de una conversión."""
success: bool
input_path: str
output_path: str
input_format: str
output_format: str
input_size: int
output_size: int
duration: float
error_message: Optional[str] = None
compression_ratio: float = 0.0
# ============================================================================
# UTILIDADES Y FUNCIONES AUXILIARES
# ============================================================================
class ImageUtils:
"""Utilidades para manejo de imágenes."""
@staticmethod
def detect_format(file_path: Path) -> Optional[str]:
"""
Detecta el formato de una imagen por su extensión.
Args:
file_path: Ruta del archivo
Returns:
Formato detectado o None
"""
ext = file_path.suffix.upper().lstrip('.')
# Manejo de alias
if ext == 'JPG':
return 'JPEG'
elif ext == 'TIF':
return 'TIFF'
if ext in Config.SUPPORTED_FORMATS:
return ext
return None
@staticmethod
def get_image_info(file_path: Path) -> Dict[str, Any]:
"""
Obtiene información detallada de una imagen.
Args:
file_path: Ruta del archivo
Returns:
Diccionario con información de la imagen
"""
info = {
'filename': file_path.name,
'file_size': file_path.stat().st_size,
'file_size_mb': round(file_path.stat().st_size / (1024 * 1024), 2),
'format': ImageUtils.detect_format(file_path),
'width': 0,
'height': 0,
'color_mode': '',
'has_alpha': False,
'metadata': {}
}
try:
with Image.open(file_path) as img:
info['width'] = img.width
info['height'] = img.height
info['color_mode'] = img.mode
info['has_alpha'] = img.mode in ('RGBA', 'LA', 'PA')
# Intentar obtener EXIF
if EXIF_SUPPORT and hasattr(img, '_getexif'):
try:
exif_data = piexif.load(str(file_path))
info['metadata'] = {
'has_exif': True,
'camera': exif_data.get('0th', {}).get(0x110),
'datetime': exif_data.get('0th', {}).get(0x132),
}
except Exception:
pass
except Exception as e:
logger.error(f"Error al obtener información de imagen: {e}")
return info
@staticmethod
def enhance_image(image: Image.Image, enhance: bool = True) -> Image.Image:
"""
Mejora la calidad de una imagen.
Args:
image: Imagen a mejorar
enhance: Si True, aplica mejoras
Returns:
Imagen mejorada
"""
if not enhance:
return image
try:
# Mejorar contraste
if Config.ENHANCE_CONTRAST:
enhancer = ImageEnhance.Contrast(image)
image = enhancer.enhance(Config.CONTRAST_FACTOR)
# Mejorar nitidez
if Config.ENHANCE_SHARPNESS:
enhancer = ImageEnhance.Sharpness(image)
image = enhancer.enhance(Config.SHARPNESS_FACTOR)
except Exception as e:
logger.warning(f"No se pudo mejorar imagen: {e}")
return image
@staticmethod
def handle_transparency(
image: Image.Image,
output_format: str,
mode: TransparencyMode = TransparencyMode.WHITE_BG
) -> Image.Image:
"""
Maneja la transparencia según el formato de salida.
Args:
image: Imagen a procesar
output_format: Formato de salida
mode: Modo de manejo de transparencia
Returns:
Imagen procesada
"""
# Si el formato soporta transparencia, retornar como está
if output_format.upper() in Config.FORMATS_WITH_TRANSPARENCY:
return image
# Si la imagen tiene transparencia y el formato no la soporta
if image.mode in ('RGBA', 'LA', 'PA'):
if mode == TransparencyMode.REMOVE:
return image.convert('RGB')
elif mode == TransparencyMode.WHITE_BG:
bg = Image.new('RGB', image.size, (255, 255, 255))
bg.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
return bg
elif mode == TransparencyMode.BLACK_BG:
bg = Image.new('RGB', image.size, (0, 0, 0))
bg.paste(image, mask=image.split()[-1] if image.mode == 'RGBA' else None)
return bg
return image
# ============================================================================
# MOTOR DE CONVERSIÓN
# ============================================================================
class ImageConverter:
"""Motor de conversión de imágenes."""
def __init__(self):
"""Inicializa el convertidor."""
self.cancel_requested = False
self.progress_callback = None
self.log_callback = None
def convert_image(
self,
input_path: Path,
output_path: Path,
output_format: str,
quality: int = 95,
preserve_transparency: bool = True,
enhance: bool = True
) -> ConversionResult:
"""
Convierte una imagen de un formato a otro.
Args:
input_path: Ruta de entrada
output_path: Ruta de salida
output_format: Formato de salida
quality: Calidad (0-100)
preserve_transparency: Preservar transparencia
enhance: Mejorar calidad
Returns:
ConversionResult con información del resultado
"""
start_time = time.time()
try:
# Validar entrada
if not input_path.exists():
return ConversionResult(
success=False,
input_path=str(input_path),
output_path=str(output_path),
input_format=ImageUtils.detect_format(input_path) or 'UNKNOWN',
output_format=output_format,
input_size=0,
output_size=0,
duration=0,
error_message=f"Archivo de entrada no encontrado: {input_path}"
)
input_format = ImageUtils.detect_format(input_path)
input_size = input_path.stat().st_size
# Abrir imagen
image = Image.open(input_path)
# Mejorar imagen si está habilitado
image = ImageUtils.enhance_image(image, enhance)
# Manejar transparencia
if not preserve_transparency:
image = ImageUtils.handle_transparency(
image,
output_format,
TransparencyMode.WHITE_BG
)
# Crear directorio de salida
output_path.parent.mkdir(parents=True, exist_ok=True)
# Opciones de guardado según formato
save_kwargs = {'format': output_format.upper()}
if output_format.upper() in ['JPEG', 'JPG']:
save_kwargs['quality'] = quality
save_kwargs['optimize'] = Config.JPEG_OPTIMIZE
if Config.JPEG_PROGRESSIVE:
save_kwargs['progressive'] = True
elif output_format.upper() == 'PNG':
save_kwargs['compress_level'] = Config.PNG_COMPRESS_LEVEL
save_kwargs['optimize'] = Config.PNG_OPTIMIZE
elif output_format.upper() == 'WEBP':
save_kwargs['quality'] = quality
save_kwargs['method'] = Config.WEBP_METHOD
elif output_format.upper() == 'TIFF':
save_kwargs['compression'] = Config.TIFF_COMPRESSION
# Guardar imagen
image.save(str(output_path), **save_kwargs)
output_size = output_path.stat().st_size
duration = time.time() - start_time
compression_ratio = (1 - output_size / input_size) * 100 if input_size > 0 else 0
conversion_logger.info(
f"{str(input_path.resolve())}|{input_format or 'UNKNOWN'}|{output_format.upper()}"
)
return ConversionResult(
success=True,
input_path=str(input_path),
output_path=str(output_path),
input_format=input_format or 'UNKNOWN',
output_format=output_format,
input_size=input_size,
output_size=output_size,
duration=duration,
compression_ratio=compression_ratio
)
except Exception as e:
error_msg = f"Error en conversión: {str(e)}"
logger.error(f"{error_msg}\n{traceback.format_exc()}")
return ConversionResult(
success=False,
input_path=str(input_path),
output_path=str(output_path),
input_format=ImageUtils.detect_format(input_path) or 'UNKNOWN',
output_format=output_format,
input_size=input_path.stat().st_size if input_path.exists() else 0,
output_size=0,
duration=time.time() - start_time,
error_message=error_msg
)
def convert_batch(
self,
input_files: List[Path],
output_dir: Path,
output_format: str,
quality: int = 95,
preserve_transparency: bool = True,
enhance: bool = True,
progress_callback=None
) -> Tuple[List[ConversionResult], int]:
"""
Convierte múltiples imágenes en paralelo.
Args:
input_files: Lista de rutas de entrada
output_dir: Directorio de salida
output_format: Formato de salida
quality: Calidad
preserve_transparency: Preservar transparencia
enhance: Mejorar calidad
progress_callback: Función callback para progreso
Returns:
Tupla (lista de resultados, cantidad de errores)
"""
self.cancel_requested = False
self.progress_callback = progress_callback
results = []
total = len(input_files)
processed = 0
try:
with ThreadPoolExecutor(max_workers=Config.MAX_WORKERS) as executor:
futures = {}
for input_file in input_files:
if self.cancel_requested:
break
output_path = output_dir / f"{input_file.stem}.{output_format.lower()}"
future = executor.submit(
self.convert_image,
input_file,
output_path,
output_format,
quality,
preserve_transparency,
enhance
)
futures[future] = input_file
for future in as_completed(futures):
if self.cancel_requested:
break
processed += 1
result = future.result()
results.append(result)
# Callback de progreso
if self.progress_callback:
self.progress_callback(processed, total, result)
except Exception as e:
logger.error(f"Error en conversión batch: {e}")
# Contar errores
errors = sum(1 for r in results if not r.success)
return results, errors
def cancel(self) -> None:
"""Solicita cancelación de conversión."""
self.cancel_requested = True
# ============================================================================
# INTERFAZ GRÁFICA MODERNA - CustomTkinter
# ============================================================================
class ImageConverterGUI:
"""Interfaz gráfica moderna para ImageConverter."""
def __init__(self, root: ctk.CTk):
"""Inicializa la interfaz gráfica."""
self.root = root
self.converter = ImageConverter()
self.selected_files: List[Path] = []
self.conversion_thread = None
self.current_image_preview = None
self.tooltip_window = None
self.file_details = {} # Almacenar información de archivos
self.thumbnail_cache = {} # Caché de miniaturas para el registro
self._setup_window()
self._create_ui()
self._setup_drag_drop()
self._bind_events()
def _setup_window(self) -> None:
"""Configura la ventana principal."""
self.root.title(Config.APP_NAME)
self.root.geometry(f"{Config.WINDOW_WIDTH}x{Config.WINDOW_HEIGHT}")
self.root.minsize(Config.WINDOW_MIN_WIDTH, Config.WINDOW_MIN_HEIGHT)
# Tema
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
# Color de fondo
self.root.configure(fg_color=Config.Colors.BG_PRIMARY)
def _create_ui(self) -> None:
"""Crea todos los elementos de la interfaz."""
# Frame principal con grid
main_frame = ctk.CTkFrame(self.root, fg_color=Config.Colors.BG_PRIMARY)
main_frame.pack(fill="both", expand=True, padx=0, pady=0)
main_frame.grid_rowconfigure(1, weight=1)
main_frame.grid_columnconfigure(0, weight=1)
# ===================== HEADER =======================
header_frame = ctk.CTkFrame(main_frame, fg_color=Config.Colors.BG_SECONDARY, height=80)
header_frame.grid(row=0, column=0, sticky="ew", padx=0, pady=0)
header_frame.grid_propagate(False)
title_label = ctk.CTkLabel(
header_frame,
text=Config.APP_NAME,
font=(Config.FONT_FAMILY, Config.FONT_SIZE_TITLE, "bold"),
text_color=Config.Colors.TEXT_PRIMARY
)
title_label.pack(padx=Config.PADDING_LARGE, pady=Config.PADDING_MEDIUM, anchor="w")
# ===================== CONTENIDO PRINCIPAL =======================
content_frame = ctk.CTkFrame(main_frame, fg_color=Config.Colors.BG_PRIMARY)
content_frame.grid(row=1, column=0, sticky="nsew", padx=0, pady=0)
content_frame.grid_columnconfigure(0, weight=1)
content_frame.grid_columnconfigure(1, weight=1)
content_frame.grid_rowconfigure(0, weight=1)
# ===================== PANEL IZQUIERDO - ARCHIVOS =======================
left_panel = ctk.CTkFrame(content_frame, fg_color=Config.Colors.BG_SECONDARY)
left_panel.grid(row=0, column=0, sticky="nsew", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_MEDIUM)
left_panel.grid_rowconfigure(4, weight=1)
left_panel.grid_columnconfigure(0, weight=1)
# Título sección
section_title = ctk.CTkLabel(
left_panel,
text="📁 Archivos a Convertir",
font=(Config.FONT_FAMILY, Config.FONT_SIZE_HEADING, "bold"),
text_color=Config.Colors.TEXT_PRIMARY
)
section_title.grid(row=0, column=0, sticky="w", padx=Config.PADDING_MEDIUM, pady=(Config.PADDING_MEDIUM, Config.PADDING_SMALL))
# Botones de selección
buttons_frame = ctk.CTkFrame(left_panel, fg_color="transparent")
buttons_frame.grid(row=1, column=0, sticky="ew", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
buttons_frame.grid_columnconfigure(0, weight=1)
buttons_frame.grid_columnconfigure(1, weight=1)
btn_files = ctk.CTkButton(
buttons_frame,
text="📁 Archivos",
command=self._select_files,
fg_color=Config.Colors.ACCENT_PRIMARY,
hover_color=Config.Colors.ACCENT_SECONDARY,
text_color="white",
font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL, "bold"),
corner_radius=Config.CORNER_RADIUS
)
btn_files.grid(row=0, column=0, sticky="ew", padx=(0, Config.PADDING_SMALL))
btn_folder = ctk.CTkButton(
buttons_frame,
text="📂 Carpeta",
command=self._select_folder,
fg_color=Config.Colors.ACCENT_PRIMARY,
hover_color=Config.Colors.ACCENT_SECONDARY,
text_color="white",
font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL, "bold"),
corner_radius=Config.CORNER_RADIUS
)
btn_folder.grid(row=0, column=1, sticky="ew", padx=Config.PADDING_SMALL)
# Información de cantidad de archivos
self.file_count_label = ctk.CTkLabel(
left_panel,
text="0 archivos seleccionados",
font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
text_color=Config.Colors.TEXT_SECONDARY
)
self.file_count_label.grid(row=2, column=0, sticky="w", padx=Config.PADDING_MEDIUM, pady=(Config.PADDING_SMALL, Config.PADDING_SMALL))
# Frame para lista con scrollbar
list_container = ctk.CTkFrame(left_panel, fg_color=Config.Colors.BG_TERTIARY, corner_radius=Config.CORNER_RADIUS)
list_container.grid(row=4, column=0, sticky="nsew", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
list_container.grid_rowconfigure(0, weight=1)
list_container.grid_columnconfigure(0, weight=1)
# Listbox con items personalizados (frame)
self.file_frame = ctk.CTkScrollableFrame(
list_container,
fg_color=Config.Colors.BG_TERTIARY,
corner_radius=Config.CORNER_RADIUS
)
self.file_frame.grid(row=0, column=0, sticky="nsew", padx=0, pady=0)
self.file_frame.grid_columnconfigure(0, weight=1)
self.file_items = [] # Lista de widgets de archivo
# Botón limpiar lista
btn_clear = ctk.CTkButton(
left_panel,
text="🗑️ Limpiar Lista",
command=self._clear_files,
fg_color=Config.Colors.BG_HOVER,
hover_color=Config.Colors.BG_SELECTED,
text_color=Config.Colors.TEXT_PRIMARY,
font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
corner_radius=Config.CORNER_RADIUS
)
btn_clear.grid(row=5, column=0, sticky="ew", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
# ===================== PANEL DERECHO - CONFIGURACIÓN =======================
right_panel = ctk.CTkFrame(content_frame, fg_color=Config.Colors.BG_SECONDARY)
right_panel.grid(row=0, column=1, sticky="nsew", padx=(Config.PADDING_SMALL, Config.PADDING_MEDIUM), pady=Config.PADDING_MEDIUM)
right_panel.grid_rowconfigure(7, weight=1)
right_panel.grid_columnconfigure(0, weight=1)
right_panel.grid_columnconfigure(1, weight=1)
# Configuración de conversión
config_title = ctk.CTkLabel(
right_panel,
text="⚙️ Configuración",
font=(Config.FONT_FAMILY, Config.FONT_SIZE_HEADING, "bold"),
text_color=Config.Colors.TEXT_PRIMARY
)
config_title.grid(row=0, column=0, columnspan=2, sticky="w", padx=Config.PADDING_MEDIUM, pady=(Config.PADDING_MEDIUM, Config.PADDING_SMALL))
# Formato de salida
format_label = ctk.CTkLabel(
right_panel,
text="Formato de Salida:",
font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL),
text_color=Config.Colors.TEXT_PRIMARY
)
format_label.grid(row=1, column=0, sticky="w", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
self.format_var = tk.StringVar(value="PNG")
format_menu = ctk.CTkOptionMenu(
right_panel,
variable=self.format_var,
values=sorted(Config.SUPPORTED_FORMATS.keys()),
fg_color=Config.Colors.BG_TERTIARY,
button_color=Config.Colors.ACCENT_PRIMARY,
dropdown_fg_color=Config.Colors.BG_TERTIARY,
text_color=Config.Colors.TEXT_PRIMARY
)
format_menu.grid(row=1, column=1, sticky="ew", padx=(Config.PADDING_SMALL, Config.PADDING_MEDIUM), pady=Config.PADDING_SMALL)
# Calidad
quality_label = ctk.CTkLabel(
right_panel,
text="Calidad:",
font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL),
text_color=Config.Colors.TEXT_PRIMARY
)
quality_label.grid(row=2, column=0, sticky="w", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
self.quality_var = tk.IntVar(value=95)
quality_frame = ctk.CTkFrame(right_panel, fg_color="transparent")
quality_frame.grid(row=2, column=1, sticky="ew", padx=(Config.PADDING_SMALL, Config.PADDING_MEDIUM), pady=Config.PADDING_SMALL)
quality_frame.grid_columnconfigure(0, weight=1)
self.quality_slider = ctk.CTkSlider(
quality_frame,
from_=50,
to=100,
number_of_steps=50,
variable=self.quality_var,
fg_color=Config.Colors.BG_TERTIARY,
progress_color=Config.Colors.ACCENT_PRIMARY
)
self.quality_slider.grid(row=0, column=0, sticky="ew", padx=(0, Config.PADDING_SMALL))
self.quality_value = ctk.CTkLabel(
quality_frame,
text="95%",
font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
text_color=Config.Colors.ACCENT_PRIMARY,
width=40
)
self.quality_value.grid(row=0, column=1, padx=0)
def update_quality_label(val):
self.quality_value.configure(text=f"{int(float(val))}%")
if hasattr(self.quality_var, 'trace_add'):
self.quality_var.trace_add('write', lambda *args: update_quality_label(self.quality_slider.get()))
else:
self.quality_var.trace('w', lambda *args: update_quality_label(self.quality_slider.get()))
# Opciones checkbox
self.transparency_var = tk.BooleanVar(value=True)
transparency_check = ctk.CTkCheckBox(
right_panel,
text="Preservar Transparencia",
variable=self.transparency_var,
text_color=Config.Colors.TEXT_PRIMARY,
fg_color=Config.Colors.ACCENT_PRIMARY,
hover_color=Config.Colors.ACCENT_SECONDARY
)
transparency_check.grid(row=3, column=0, columnspan=2, sticky="w", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
self.enhance_var = tk.BooleanVar(value=True)
enhance_check = ctk.CTkCheckBox(
right_panel,
text="Mejorar Calidad (Contraste + Nitidez)",
variable=self.enhance_var,
text_color=Config.Colors.TEXT_PRIMARY,
fg_color=Config.Colors.ACCENT_PRIMARY,
hover_color=Config.Colors.ACCENT_SECONDARY
)
enhance_check.grid(row=4, column=0, columnspan=2, sticky="w", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
# Carpeta de salida
output_label = ctk.CTkLabel(
right_panel,
text="Carpeta de Salida:",
font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL),
text_color=Config.Colors.TEXT_PRIMARY
)
output_label.grid(row=5, column=0, columnspan=2, sticky="w", padx=Config.PADDING_MEDIUM, pady=(Config.PADDING_MEDIUM, Config.PADDING_SMALL))
output_frame = ctk.CTkFrame(right_panel, fg_color="transparent")
output_frame.grid(row=6, column=0, columnspan=2, sticky="ew", padx=Config.PADDING_MEDIUM, pady=Config.PADDING_SMALL)
output_frame.grid_columnconfigure(0, weight=1)
self.output_dir = tk.StringVar(value="")
output_entry = ctk.CTkEntry(
output_frame,
textvariable=self.output_dir,
fg_color=Config.Colors.BG_TERTIARY,
text_color=Config.Colors.TEXT_PRIMARY,
border_color=Config.Colors.BORDER,
border_width=1,
state="readonly"
)
output_entry.grid(row=0, column=0, sticky="ew", padx=(0, Config.PADDING_SMALL))
btn_output = ctk.CTkButton(
output_frame,
text="📂",
command=self._select_output_folder,
fg_color=Config.Colors.ACCENT_PRIMARY,
hover_color=Config.Colors.ACCENT_SECONDARY,
width=40,
corner_radius=Config.CORNER_RADIUS
)
btn_output.grid(row=0, column=1, padx=0)
# ===================== FOOTER CON BOTONES =======================
footer_frame = ctk.CTkFrame(main_frame, fg_color=Config.Colors.BG_SECONDARY)
footer_frame.grid(row=2, column=0, sticky="ew", padx=0, pady=0)
# Barra de progreso
self.progress_var = tk.DoubleVar(value=0)
self.progress_bar = ctk.CTkProgressBar(
footer_frame,
variable=self.progress_var,
fg_color=Config.Colors.BG_TERTIARY,
progress_color=Config.Colors.ACCENT_PRIMARY,
height=8
)
self.progress_bar.pack(fill="x", padx=0, pady=0)
# Frame de botones
button_frame = ctk.CTkFrame(footer_frame, fg_color=Config.Colors.BG_SECONDARY)
button_frame.pack(fill="x", padx=Config.PADDING_LARGE, pady=Config.PADDING_MEDIUM)
button_frame.grid_columnconfigure(0, weight=1)
# Información de progreso
self.progress_label = ctk.CTkLabel(
button_frame,
text="Listo",
font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
text_color=Config.Colors.TEXT_SECONDARY
)
self.progress_label.grid(row=0, column=0, sticky="w", padx=0, pady=0)
# Botones de acción
self.btn_convert = ctk.CTkButton(
button_frame,
text="▶ Convertir",
command=self._start_conversion,
fg_color=Config.Colors.ACCENT_PRIMARY,
hover_color=Config.Colors.ACCENT_SECONDARY,
text_color="white",
font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL, "bold"),
corner_radius=Config.CORNER_RADIUS,
width=150,
state="disabled"
)
self.btn_convert.grid(row=0, column=1, padx=Config.PADDING_SMALL)
self.btn_cancel = ctk.CTkButton(
button_frame,
text="⏹ Cancelar",
command=self._cancel_conversion,
fg_color=Config.Colors.ERROR,
hover_color="#bb2d3b",
text_color="white",
font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL, "bold"),
corner_radius=Config.CORNER_RADIUS,
width=150,
state="disabled"
)
self.btn_cancel.grid(row=0, column=2, padx=Config.PADDING_SMALL)
btn_logs = ctk.CTkButton(
button_frame,
text="📋 Registros",
command=self._show_logs,
fg_color=Config.Colors.BG_HOVER,
hover_color=Config.Colors.BG_SELECTED,
text_color=Config.Colors.TEXT_PRIMARY,
font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL),
corner_radius=Config.CORNER_RADIUS,
width=150
)
btn_logs.grid(row=0, column=3, padx=Config.PADDING_SMALL)
btn_about = ctk.CTkButton(
button_frame,
text="ℹ️ Acerca de",
command=self._show_about,
fg_color=Config.Colors.BG_HOVER,
hover_color=Config.Colors.BG_SELECTED,
text_color=Config.Colors.TEXT_PRIMARY,
font=(Config.FONT_FAMILY, Config.FONT_SIZE_NORMAL),
corner_radius=Config.CORNER_RADIUS,
width=150
)
btn_about.grid(row=0, column=4, padx=Config.PADDING_SMALL)
def _setup_drag_drop(self) -> None:
"""Configura drag & drop."""
try:
from tkinterdnd2 import DND_FILES, DND_TEXT
def drop(event):
files = self.root.tk.splitlist(event.data)
for file_path in files:
file_path = Path(file_path.strip('{}'))
if file_path.is_file() and ImageUtils.detect_format(file_path):
if file_path not in self.selected_files:
self.selected_files.append(file_path)
self._update_file_list()
if hasattr(self.root, 'drop_target_register'):
self.root.drop_target_register(DND_FILES)
self.root.dnd_bind('<>', drop)
else:
logger.info("Drag & Drop no está soportado por la ventana principal actual.")
except ImportError:
logger.info("tkinterdnd2 no disponible - Drag & Drop deshabilitado")
def _bind_events(self) -> None:
"""Vincula eventos de la interfaz."""
pass
def _select_files(self) -> None:
"""Abre diálogo para seleccionar archivos."""
filetypes = [
("Imágenes", ' '.join(f"*.{fmt.lower()}" for fmt in Config.SUPPORTED_FORMATS)),
("PNG", "*.png"),
("JPEG", "*.jpg *.jpeg"),
("WEBP", "*.webp"),
("GIF", "*.gif"),
("TIFF", "*.tiff *.tif"),
("Todos", "*.*")
]
files = filedialog.askopenfilenames(filetypes=filetypes)
for file in files:
file_path = Path(file)
if ImageUtils.detect_format(file_path) and file_path not in self.selected_files:
self.selected_files.append(file_path)
self._update_file_list()
def _select_folder(self) -> None:
"""Abre diálogo para seleccionar carpeta."""
folder = filedialog.askdirectory()
if folder:
folder_path = Path(folder)
image_files = [
f for f in folder_path.rglob("*")
if f.is_file() and ImageUtils.detect_format(f)
]
for file in image_files:
if file not in self.selected_files:
self.selected_files.append(file)
self._update_file_list()
def _select_output_folder(self) -> None:
"""Abre diálogo para seleccionar carpeta de salida."""
folder = filedialog.askdirectory()
if folder:
self.output_dir.set(folder)
# Habilitar botón de conversión cuando se selecciona carpeta de salida
self.btn_convert.configure(state="normal")
def _clear_files(self) -> None:
"""Limpia la lista de archivos."""
self.selected_files.clear()
self._update_file_list()
def _update_file_list(self) -> None:
"""Actualiza la lista visual de archivos con previsualizaciones."""
# Limpiar items anteriores
for item in self.file_items:
item.destroy()
self.file_items.clear()
self.file_details.clear()
# Actualizar contador
self.file_count_label.configure(
text=f"{len(self.selected_files)} archivo{'s' if len(self.selected_files) != 1 else ''} seleccionado{'s' if len(self.selected_files) != 1 else ''}"
)
# Crear items nuevos
for idx, file_path in enumerate(self.selected_files):
# Obtener información del archivo
info = ImageUtils.get_image_info(file_path)
self.file_details[str(file_path)] = info
# Frame del item
item_frame = ctk.CTkFrame(
self.file_frame,
fg_color=Config.Colors.BG_SECONDARY,
corner_radius=Config.CORNER_RADIUS,
height=100
)
item_frame.pack(fill="x", padx=Config.PADDING_SMALL, pady=Config.PADDING_TINY, expand=False)
item_frame.grid_propagate(False)
item_frame.grid_columnconfigure(1, weight=1)
# Miniatura en la lista
thumbnail_image = None
try:
if file_path.exists():
thumb = Image.open(file_path)
thumb.thumbnail((90, 90), Image.Resampling.LANCZOS)
thumbnail_image = ctk.CTkImage(light_image=thumb, size=(90, 90))
# Guardar en caché usando la ruta absoluta como clave
self.thumbnail_cache[str(file_path.resolve())] = thumbnail_image
except Exception:
thumbnail_image = None
preview_label = ctk.CTkLabel(
item_frame,
image=thumbnail_image,
text="" if thumbnail_image else "🖼️",
fg_color=Config.Colors.BG_SECONDARY,
text_color=Config.Colors.ACCENT_PRIMARY,
width=100,
height=100
)
preview_label.image = thumbnail_image
preview_label.grid(row=0, column=0, padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
# Información del archivo
info_text = f"{file_path.name}\n{info['width']}x{info['height']} • {info['file_size_mb']} MB"
info_label = ctk.CTkLabel(
item_frame,
text=info_text,
font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
text_color=Config.Colors.TEXT_PRIMARY,
justify="left",
anchor="w"
)
info_label.grid(row=0, column=1, sticky="w", padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
# Botón para eliminar
btn_remove = ctk.CTkButton(
item_frame,
text="✕",
font=(Config.FONT_FAMILY, 16, "bold"),
fg_color=Config.Colors.ERROR,
hover_color="#bb2d3b",
text_color="white",
width=40,
height=40,
corner_radius=Config.CORNER_RADIUS,
command=lambda fp=file_path: self._remove_file(fp)
)
btn_remove.grid(row=0, column=2, padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
# Evento hover para tooltip
def on_enter(event, fp=file_path, info_d=info):
self._show_tooltip(event, fp, info_d)
def on_leave(event):
self._hide_tooltip()
item_frame.bind("", on_enter)
item_frame.bind("", on_leave)
preview_label.bind("", on_enter)
preview_label.bind("", on_leave)
info_label.bind("", on_enter)
info_label.bind("", on_leave)
self.file_items.append(item_frame)
def _remove_file(self, file_path: Path) -> None:
"""Elimina un archivo de la lista."""
if file_path in self.selected_files:
self.selected_files.remove(file_path)
self._update_file_list()
def _show_tooltip(self, event, file_path: Path, info: Dict) -> None:
"""Muestra un tooltip con información y miniatura."""
try:
# Ocultar tooltip anterior
self._hide_tooltip()
# Crear ventana tooltip
self.tooltip_window = tk.Toplevel()
self.tooltip_window.wm_overrideredirect(True)
self.tooltip_window.wm_geometry(f"+{event.x_root + 10}+{event.y_root + 10}")
self.tooltip_window.configure(bg=Config.Colors.BG_TERTIARY, highlightthickness=1,
highlightcolor=Config.Colors.BORDER)
# Frame principal
tooltip_frame = ctk.CTkFrame(self.tooltip_window, fg_color=Config.Colors.BG_TERTIARY)
tooltip_frame.pack(padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
# Información formateada
tooltip_text = f"""
📄 {file_path.name}
📐 Resolución: {info['width']}x{info['height']}
💾 Tamaño: {info['file_size_mb']} MB
🎨 Modo: {info['color_mode']}
🔄 Transparencia: {'Sí' if info['has_alpha'] else 'No'}
"""
info_label = ctk.CTkLabel(
tooltip_frame,
text=tooltip_text,
font=(Config.FONT_FAMILY, Config.FONT_SIZE_TINY),
text_color=Config.Colors.TEXT_PRIMARY,
justify="left"
)
info_label.pack(padx=Config.PADDING_MEDIUM, pady=Config.PADDING_MEDIUM)
self.root.after(5000, self._hide_tooltip)
except Exception as e:
logger.warning(f"Error al mostrar tooltip: {e}")
def _hide_tooltip(self) -> None:
"""Oculta el tooltip."""
if self.tooltip_window:
try:
self.tooltip_window.destroy()
except Exception:
pass
self.tooltip_window = None
def _start_conversion(self) -> None:
"""Inicia la conversión de archivos."""
if not self.selected_files:
messagebox.showwarning("Advertencia", "Por favor selecciona al menos un archivo")
return
output_format = self.format_var.get()
output_dir_str = self.output_dir.get().strip()
# Validar que la carpeta de salida esté seleccionada
if not output_dir_str:
messagebox.showerror("Error", "Debes seleccionar una carpeta de salida antes de convertir")
return
output_dir = Path(output_dir_str)
quality = self.quality_var.get()
preserve_transparency = self.transparency_var.get()
enhance = self.enhance_var.get()
if not output_dir.exists():
messagebox.showerror("Error", f"Carpeta de salida no existe: {output_dir}")
return
# Deshabilitar botón de convertir
self.root.after(0, self._disable_controls)
# Iniciar conversión en hilo separado
self.conversion_thread = threading.Thread(
target=self._perform_conversion,
args=(output_format, output_dir, quality, preserve_transparency, enhance),
daemon=True
)
self.conversion_thread.start()
def _disable_controls(self) -> None:
"""Deshabilita controles durante conversión."""
self.btn_convert.configure(state="disabled")
self.btn_cancel.configure(state="normal")
def _enable_controls(self) -> None:
"""Habilita controles después de conversión."""
self.btn_convert.configure(state="normal")
self.btn_cancel.configure(state="disabled")
def _perform_conversion(
self,
output_format: str,
output_dir: Path,
quality: int,
preserve_transparency: bool,
enhance: bool
) -> None:
"""Realiza la conversión en hilo separado."""
def progress_callback(current, total, result):
percentage = (current / total) * 100
self.progress_var.set(percentage / 100)
status_msg = f"Convertido {current}/{total}"
if result.error_message:
status_msg += f" | Error: {result.error_message}"
else:
size_reduction = result.compression_ratio
status_msg += f" | Reducción: {size_reduction:.1f}%"
self.progress_label.configure(text=status_msg)
self.root.update_idletasks()
try:
results, errors = self.converter.convert_batch(
self.selected_files,
output_dir,
output_format,
quality,
preserve_transparency,
enhance,
progress_callback
)
# Mostrar resumen
successful = len(results) - errors
summary = f"Conversión completada:\n\n✓ {successful} archivos convertidos\n✗ {errors} errores"
self.root.after(0, lambda: messagebox.showinfo("Éxito", summary))
self.root.after(0, self._enable_controls)
self.root.after(0, lambda: self.progress_var.set(0))
self.root.after(0, lambda: self.progress_label.configure(text="Listo"))
self.root.after(0, lambda: self.output_dir.set(""))
except Exception as e:
logger.error(f"Error en conversión: {e}")
self.root.after(0, lambda: messagebox.showerror("Error", f"Error: {str(e)}"))
self.root.after(0, self._enable_controls)
def _cancel_conversion(self) -> None:
"""Cancela la conversión actual."""
self.converter.cancel()
self.root.after(0, self._enable_controls)
def _show_logs(self) -> None:
"""Muestra ventana de registros."""
log_window = ctk.CTkToplevel(self.root)
log_window.title("Registros de Conversión")
log_window.geometry("900x650")
log_window.configure(fg_color=Config.Colors.BG_PRIMARY)
# Configurar como ventana modal (siempre encima)
log_window.attributes('-topmost', True)
log_window.grab_set()
# Centrar sobre la ventana principal
log_window.update_idletasks()
root_x = self.root.winfo_x()
root_y = self.root.winfo_y()
root_width = self.root.winfo_width()
root_height = self.root.winfo_height()
window_width = log_window.winfo_width()
window_height = log_window.winfo_height()
x = root_x + (root_width - window_width) // 2
y = root_y + (root_height - window_height) // 2
log_window.geometry(f"+{x}+{y}")
# Leer archivo de log
try:
with open(Config.LOG_FILE, 'rb') as f:
raw_content = f.read().decode('utf-8', errors='replace')
except FileNotFoundError:
raw_content = ""
except Exception as e:
raw_content = f"No se pudo leer el archivo de log: {e}"
# Crear panel scrollable de resultados
log_body = ctk.CTkScrollableFrame(log_window, fg_color=Config.Colors.BG_PRIMARY, corner_radius=Config.CORNER_RADIUS)
log_body.pack(fill="both", expand=True, padx=Config.PADDING_MEDIUM, pady=(Config.PADDING_MEDIUM, 0))
log_body.grid_columnconfigure(0, weight=1)
# Almacenar referencias de imágenes en la ventana para evitar que se limpien
if not hasattr(log_window, '_image_refs'):
log_window._image_refs = []
lines_found = 0
for line in raw_content.splitlines():
parsed_entry = parse_conversion_log_entry(line)
if parsed_entry is None:
continue
image_path, input_fmt, output_fmt = parsed_entry
filename = image_path.name
conversion_text = f"{filename} | {input_fmt} -> {output_fmt}"
item_frame = ctk.CTkFrame(log_body, fg_color=Config.Colors.BG_SECONDARY, corner_radius=Config.CORNER_RADIUS)
item_frame.pack(fill="x", padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
item_frame.grid_columnconfigure(1, weight=1)
# Buscar la miniatura en caché usando ruta absoluta
thumbnail_image = self.thumbnail_cache.get(str(image_path.resolve()))
preview_label = ctk.CTkLabel(
item_frame,
image=thumbnail_image,
text="" if thumbnail_image else "🖼️",
fg_color=Config.Colors.BG_SECONDARY,
text_color=Config.Colors.ACCENT_PRIMARY,
width=100,
height=100
)
preview_label.image = thumbnail_image
preview_label.grid(row=0, column=0, padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
info_label = ctk.CTkLabel(
item_frame,
text=conversion_text,
font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
text_color=Config.Colors.TEXT_PRIMARY,
justify="left"
)
info_label.grid(row=0, column=1, sticky="w", padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
lines_found += 1
if lines_found == 0:
empty_label = ctk.CTkLabel(
log_body,
text="No hay registros de conversión.",
font=(Config.FONT_FAMILY, Config.FONT_SIZE_SMALL),
text_color=Config.Colors.TEXT_SECONDARY,
fg_color=Config.Colors.BG_SECONDARY,
corner_radius=Config.CORNER_RADIUS,
anchor="w"
)
empty_label.pack(fill="x", padx=Config.PADDING_SMALL, pady=Config.PADDING_SMALL)
# Botón cerrar
close_btn = ctk.CTkButton(
log_window,
text="Cerrar",
command=log_window.destroy,
fg_color=Config.Colors.ACCENT_PRIMARY,
corner_radius=Config.CORNER_RADIUS
)
close_btn.pack(pady=Config.PADDING_MEDIUM, padx=Config.PADDING_MEDIUM, fill="x")
def _show_about(self) -> None:
"""Muestra diálogo de información."""
about_text = f"""{Config.APP_NAME}
Versión: {Config.APP_VERSION}
{Config.APP_DESCRIPTION}
Características:
• Soporte para {len(Config.SUPPORTED_FORMATS)}+ formatos de imagen
• Conversión batch paralela
• Interfaz moderna y responsive
• Mejora automática de calidad
• Preservación de metadatos
• Soporte para GIF animados
Desarrollado con:
• Python 3.10+
• Pillow (PIL)
• CustomTkinter
• Threading/Concurrent.futures
© 2026 - Todos los derechos reservados
"""
messagebox.showinfo(Config.APP_NAME, about_text)
# ============================================================================
# PUNTO DE ENTRADA
# ============================================================================
def main() -> None:
"""Función principal de la aplicación."""
try:
# Limpiar archivo de conversiones al iniciar
reset_conversion_log()
logger.info(f"Iniciando {Config.APP_NAME} v{Config.APP_VERSION}")
# Crear ventana principal
try:
from tkinterdnd2 import TkinterDnD
class CTkDnD(ctk.CTk, TkinterDnD.DnDWrapper):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.TkdndVersion = TkinterDnD._require(self)
root = CTkDnD()
except ImportError:
root = ctk.CTk()
# Crear interfaz
app = ImageConverterGUI(root)
logger.info("Interfaz gráfica cargada exitosamente")
# Ejecutar
root.protocol("WM_DELETE_WINDOW", root.destroy)
root.mainloop()
except Exception as e:
logger.error(f"Error fatal: {e}\n{traceback.format_exc()}")
print(f"Error fatal: {e}")
sys.exit(1)
if __name__ == "__main__":
main()