diff --git a/ImgResizer.exe b/ImgResizer.exe deleted file mode 100644 index 9dff3b0..0000000 Binary files a/ImgResizer.exe and /dev/null differ diff --git a/imgsout/aseo_alpino_ducha_salvia.jpg b/imgsout/aseo_alpino_ducha_salvia.jpg deleted file mode 100644 index 889750e..0000000 Binary files a/imgsout/aseo_alpino_ducha_salvia.jpg and /dev/null differ diff --git a/imgsout/aseo_alpino_ducha_trentino.jpg b/imgsout/aseo_alpino_ducha_trentino.jpg deleted file mode 100644 index 5757ba7..0000000 Binary files a/imgsout/aseo_alpino_ducha_trentino.jpg and /dev/null differ diff --git a/imgsout/aseo_alpino_ducha_zucchero.jpg b/imgsout/aseo_alpino_ducha_zucchero.jpg deleted file mode 100644 index 644d7ae..0000000 Binary files a/imgsout/aseo_alpino_ducha_zucchero.jpg and /dev/null differ diff --git a/main.go b/main.go index 5322bc8..69503a2 100644 --- a/main.go +++ b/main.go @@ -1,16 +1,16 @@ /* - —————————————————————————————————————————————————————————————————————————— - Proyecto ImgResizer +—————————————————————————————————————————————————————————————————————————— +Proyecto Imaginator -————————————————————————————————————————————————————————————————————————————— +————————————————————————————————————————————————————————————————————————— Fichero main.go - Package ImgResizer + Package Imaginator Autor Juan V. Navarro juanvnl@activething.com Creado 26/01/2026 - Mejorado 26/01/2026 + Mejorado 20/03/2026 -————————————————————————————————————————————————————————————————————————————— +————————————————————————————————————————————————————————————————————————— LICENSES AND TERMS OF USE ------------------------- @@ -29,32 +29,23 @@ commercial product without first obtaining a commercial license from Active Thing. - You may combine this software with other code, provided that such - combination does not circumvent the restrictions of this License. +————————————————————————————————————————————————————————————————————————— - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. In no event shall - the authors or copyright holders be liable for any claim, damages, or other - liability arising from the use of this software. - -————————————————————————————————————————————————————————————————————————————— - - Web : activething.com | activething.com/ImgResizer - git : g.activething.com | github.com/activething/ImgResizer + Web : activething.com | activething.com/Imaginator + git : g.activething.com | github.com/activething/Imaginator Correo : dev@activething.com -————————————————————————————————————————————————————————————————————————————— +————————————————————————————————————————————————————————————————————————— - No deseo caminar sobre el agua", dijo Siddhartha. + "No deseo caminar sobre el agua", dijo Siddhartha. Que los antiguos chamanes se contenten con tales habilidades. —— Hermann Hesse, Siddhartha -————————————————————————————————————————————————————————————————————————————— +————————————————————————————————————————————————————————————————————————— Copyright (c) 2026 Active Thing -————————————————————————————————————————————————————————————————————————————— +————————————————————————————————————————————————————————————————————————— */ package main @@ -67,14 +58,25 @@ import ( "log" "os" "path/filepath" + "regexp" "strings" "sync" "sync/atomic" + "time" + "unicode" "github.com/disintegration/imaging" + _ "golang.org/x/image/bmp" + "golang.org/x/image/tiff" _ "golang.org/x/image/webp" ) +const version = "2.3.0" + +// ───────────────────────────────────────────────────────────────────────────── +// Tipos +// ───────────────────────────────────────────────────────────────────────────── + type Config struct { InputDir string OutputDir string @@ -86,14 +88,43 @@ type Config struct { Workers int Verbose bool KeepStructure bool + Prefix string + Suffix string + DryRun bool + Replace string // pares orig/dest separados por coma, case sensitive + ReplaceI string // ídem, case insensitive + ReplaceP string // ídem, case insensitive + preserve case + FileFilter string // glob sobre el nombre del fichero + Lowercase bool // normalizar nombre de salida a minúsculas + Uppercase bool // normalizar nombre de salida a mayúsculas +} + +// ReplacePair representa un par de sustitución ya parseado +type ReplacePair struct { + From string + To string } type ProcessResult struct { - Filename string - Success bool - Error error + Filename string + OutputPath string + Success bool + Error error + Duration time.Duration + InputSize int64 + OutputSize int64 +} + +type ImageSummary struct { + Files []string + ByExt map[string]int + ReadOnlyExts []string // extensiones presentes que son sólo lectura } +// ───────────────────────────────────────────────────────────────────────────── +// Entrada +// ───────────────────────────────────────────────────────────────────────────── + func main() { cfg := parseFlags() @@ -105,34 +136,78 @@ func main() { log.Fatalf("Error creando directorio de salida: %v", err) } - files, err := collectImageFiles(cfg.InputDir, cfg.KeepStructure) + summary, err := collectImageFiles(cfg.InputDir, cfg.FileFilter) if err != nil { log.Fatalf("Error leyendo directorio: %v", err) } - if len(files) == 0 { - log.Println("No se encontraron imágenes para procesar") + if len(summary.Files) == 0 { + fmt.Println(" No se encontraron imágenes para procesar.") return } - fmt.Printf("Encontradas %d imágenes. Procesando con %d workers...\n\n", len(files), cfg.Workers) + // Resumen de tipos encontrados + fmt.Printf(" Encontradas %d imágenes:\n", len(summary.Files)) + for _, ext := range []string{".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".webp"} { + if n := summary.ByExt[ext]; n > 0 { + line := fmt.Sprintf(" %-8s %d", ext, n) + if (ext == ".webp" || ext == ".bmp") && cfg.Format == "" { + line += " ⚠ sólo lectura" + } + fmt.Println(line) + } + } + + if len(summary.ReadOnlyExts) > 0 && cfg.Format == "" { + joined := strings.Join(summary.ReadOnlyExts, ", ") + fmt.Println() + fmt.Printf(" AVISO: el lote contiene formatos sólo lectura (%s).\n", joined) + fmt.Println(" Los ficheros de salida en ese formato pueden quedar corruptos.") + fmt.Println(" Añade -formato jpg o -formato png para convertirlos.") + } + fmt.Println() + + if cfg.DryRun { + fmt.Println(" [dry-run] Rutas de salida:") + fmt.Println() + for _, f := range summary.Files { + out := buildOutputPath(f, cfg) + fmt.Printf(" %s → %s\n", f, out) + } + fmt.Println() + return + } - processImages(files, cfg) + fmt.Printf(" Procesando con %d workers...\n\n", cfg.Workers) + processImages(summary.Files, cfg) } +// ───────────────────────────────────────────────────────────────────────────── +// Flags y validación +// ───────────────────────────────────────────────────────────────────────────── + func parseFlags() *Config { cfg := &Config{} flag.StringVar(&cfg.InputDir, "dir", "", "Directorio de origen") flag.StringVar(&cfg.OutputDir, "out", "", "Directorio de destino") - flag.IntVar(&cfg.Width, "width", 0, "Ancho objetivo (mantiene proporción si height=0)") - flag.IntVar(&cfg.Height, "height", 0, "Alto objetivo (mantiene proporción si width=0)") - flag.IntVar(&cfg.Quality, "calidad", 100, "Calidad JPEG (1-100)") - flag.StringVar(&cfg.Format, "formato", "", "Formato de salida: jpg, png (vacío=mantener original)") - flag.StringVar(&cfg.Filter, "filtro", "lanczos", "Filtro de redimensionado: lanczos, cubic, linear, box") - flag.IntVar(&cfg.Workers, "workers", 4, "Número de workers concurrentes") - flag.BoolVar(&cfg.Verbose, "verbose", false, "Mostrar información detallada") + flag.IntVar(&cfg.Width, "width", 0, "Ancho objetivo") + flag.IntVar(&cfg.Height, "height", 0, "Alto objetivo") + flag.IntVar(&cfg.Quality, "calidad", 85, "Calidad JPEG (1-100)") + flag.StringVar(&cfg.Format, "formato", "", "Formato de salida: jpg, png, tif") + flag.StringVar(&cfg.Filter, "filtro", "lanczos", "Filtro: lanczos, cubic, linear, box") + flag.IntVar(&cfg.Workers, "workers", 4, "Workers concurrentes") + flag.BoolVar(&cfg.Verbose, "verbose", false, "Información detallada") flag.BoolVar(&cfg.KeepStructure, "estructura", false, "Mantener estructura de subdirectorios") + flag.StringVar(&cfg.Prefix, "prefijo", "", "Prefijo para el nombre de salida") + flag.StringVar(&cfg.Suffix, "sufijo", "", "Sufijo para el nombre de salida (antes de la extensión)") + flag.BoolVar(&cfg.DryRun, "dry-run", false, "Mostrar qué se procesaría sin ejecutar nada") + flag.StringVar(&cfg.Replace, "replace", "", "Sustituir tokens en nombre: orig/dest[,orig2/dest2] (case sensitive)") + flag.StringVar(&cfg.ReplaceI, "replacei", "", "Sustituir tokens en nombre: orig/dest[,...] (case insensitive)") + flag.StringVar(&cfg.ReplaceP, "replacep", "", "Sustituir tokens en nombre: orig/dest[,...] (insensitive + preserve case)") + flag.StringVar(&cfg.FileFilter, "filter", "", "Glob de nombre de fichero a procesar, ej: foto_*.jpg") + flag.BoolVar(&cfg.Lowercase, "lowercase", false, "Normalizar nombre de salida a minúsculas") + flag.BoolVar(&cfg.Uppercase, "uppercase", false, "Normalizar nombre de salida a mayúsculas") configurarAyuda() flag.Parse() @@ -143,7 +218,7 @@ func parseFlags() *Config { func validateConfig(cfg *Config) error { if cfg.InputDir == "" || cfg.OutputDir == "" { flag.Usage() - return fmt.Errorf("faltan parámetros obligatorios") + return fmt.Errorf("faltan parámetros obligatorios: -dir y -out son requeridos") } if cfg.Width == 0 && cfg.Height == 0 { @@ -158,8 +233,11 @@ func validateConfig(cfg *Config) error { return fmt.Errorf("la calidad debe estar entre 1 y 100") } - if cfg.Format != "" && cfg.Format != "jpg" && cfg.Format != "jpeg" && cfg.Format != "png" { - return fmt.Errorf("formato no soportado: %s (use jpg o png)", cfg.Format) + if cfg.Format != "" && + cfg.Format != "jpg" && cfg.Format != "jpeg" && + cfg.Format != "png" && + cfg.Format != "tif" && cfg.Format != "tiff" { + return fmt.Errorf("formato de salida no soportado: %s (opciones: jpg, png, tif)", cfg.Format) } absInput, err := filepath.Abs(cfg.InputDir) @@ -172,8 +250,10 @@ func validateConfig(cfg *Config) error { return fmt.Errorf("error resolviendo ruta de salida: %v", err) } - if absInput == absOutput { - return fmt.Errorf("el directorio de entrada y salida no pueden ser el mismo") + if absInput == absOutput && cfg.Prefix == "" && cfg.Suffix == "" { + return fmt.Errorf( + "el directorio de entrada y salida no pueden ser el mismo\n" + + " consejo: usa -prefijo o -sufijo para procesar en el mismo directorio sin sobreescribir los originales") } if _, err := os.Stat(cfg.InputDir); os.IsNotExist(err) { @@ -184,112 +264,233 @@ func validateConfig(cfg *Config) error { cfg.Workers = 1 } + // Validar pares de sustitución + for _, raw := range []string{cfg.Replace, cfg.ReplaceI, cfg.ReplaceP} { + if raw == "" { + continue + } + for _, pair := range strings.Split(raw, ",") { + parts := strings.SplitN(pair, "/", 2) + if len(parts) != 2 || parts[0] == "" { + return fmt.Errorf("par de sustitución inválido %q — formato esperado: original/destino", pair) + } + } + } + + // Validar glob de filtro + if cfg.FileFilter != "" { + if _, err := filepath.Match(cfg.FileFilter, ""); err != nil { + return fmt.Errorf("patrón -filter inválido %q: %v", cfg.FileFilter, err) + } + } + + if cfg.Lowercase && cfg.Uppercase { + return fmt.Errorf("-lowercase y -uppercase son mutuamente excluyentes") + } + return nil } -func collectImageFiles(root string, keepStructure bool) ([]string, error) { - var files []string +// ───────────────────────────────────────────────────────────────────────────── +// Recolección de ficheros +// ───────────────────────────────────────────────────────────────────────────── + +func collectImageFiles(root string, glob string) (*ImageSummary, error) { validExts := map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, + ".tif": true, + ".tiff": true, + ".bmp": true, + } + + readOnlyExts := map[string]bool{ + ".webp": true, + ".bmp": true, } + summary := &ImageSummary{ + ByExt: make(map[string]int), + } + + seen := map[string]bool{} + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - if info.IsDir() { return nil } - ext := strings.ToLower(filepath.Ext(path)) - if validExts[ext] { - files = append(files, path) + if !validExts[ext] { + return nil + } + // Aplicar glob sobre el nombre del fichero (sin ruta) + if glob != "" { + matched, merr := filepath.Match(glob, filepath.Base(path)) + if merr != nil || !matched { + return nil + } + } + summary.Files = append(summary.Files, path) + summary.ByExt[ext]++ + if readOnlyExts[ext] && !seen[ext] { + summary.ReadOnlyExts = append(summary.ReadOnlyExts, ext) + seen[ext] = true } - return nil }) - return files, err + return summary, err +} + +// ───────────────────────────────────────────────────────────────────────────── +// Ruta de salida +// ───────────────────────────────────────────────────────────────────────────── + +func buildOutputPath(inputPath string, cfg *Config) string { + relPath, err := filepath.Rel(cfg.InputDir, inputPath) + if err != nil { + relPath = filepath.Base(inputPath) + } + + base := filepath.Base(relPath) + dir := filepath.Dir(relPath) + + ext := filepath.Ext(base) + stem := base[:len(base)-len(ext)] + + // Formato de salida + outExt := ext + if cfg.Format != "" { + outExt = "." + cfg.Format + switch cfg.Format { + case "jpeg": + outExt = ".jpg" + case "tiff": + outExt = ".tif" + } + } + + stem = applyStemReplacements(stem, cfg) + + // Normalización de case — se aplica sobre el stem ya transformado, + // pero nunca sobre la extensión (evita .JPG o .TIF en sistemas sensibles) + switch { + case cfg.Lowercase: + stem = strings.ToLower(stem) + case cfg.Uppercase: + stem = strings.ToUpper(stem) + } + + newName := cfg.Prefix + stem + cfg.Suffix + outExt + + if cfg.KeepStructure { + return filepath.Join(cfg.OutputDir, dir, newName) + } + return filepath.Join(cfg.OutputDir, newName) } +// ───────────────────────────────────────────────────────────────────────────── +// Orquestación de workers +// ───────────────────────────────────────────────────────────────────────────── + func processImages(files []string, cfg *Config) { var ( wg sync.WaitGroup + mu sync.Mutex processed atomic.Int32 - errors atomic.Int32 + errCount atomic.Int32 + totalIn atomic.Int64 + totalOut atomic.Int64 total = int32(len(files)) + start = time.Now() ) jobs := make(chan string, len(files)) - results := make(chan ProcessResult, len(files)) - // Iniciar workers for i := 0; i < cfg.Workers; i++ { wg.Add(1) go func() { defer wg.Done() for inputPath := range jobs { result := processImage(inputPath, cfg) - results <- result - current := processed.Add(1) + + mu.Lock() if result.Success { + totalIn.Add(result.InputSize) + totalOut.Add(result.OutputSize) if cfg.Verbose { - fmt.Printf("[%d/%d] ✓ %s\n", current, total, result.Filename) + fmt.Printf(" [%d/%d] ✓ %-40s → %-40s (%s)\n", + current, total, + truncate(result.Filename, 40), + truncate(filepath.Base(result.OutputPath), 40), + result.Duration.Round(time.Millisecond)) + } else { + fmt.Printf(" [%d/%d] ✓ %s\n", current, total, result.Filename) } } else { - errors.Add(1) - fmt.Printf("[%d/%d] ✗ %s: %v\n", current, total, result.Filename, result.Error) + errCount.Add(1) + fmt.Printf(" [%d/%d] ✗ %s: %v\n", current, total, result.Filename, result.Error) } + mu.Unlock() } }() } - // Enviar trabajos - for _, file := range files { - jobs <- file + for _, f := range files { + jobs <- f } close(jobs) - - // Esperar completar wg.Wait() - close(results) - - // Resumen final - fmt.Printf("\n═══════════════════════════════════════════════════════\n") - fmt.Printf("Procesamiento completado:\n") - fmt.Printf(" Total: %d imágenes\n", total) - fmt.Printf(" Exitosas: %d\n", total-errors.Load()) - fmt.Printf(" Errores: %d\n", errors.Load()) - fmt.Printf("═══════════════════════════════════════════════════════\n") + + elapsed := time.Since(start).Round(time.Millisecond) + ok := total - errCount.Load() + inMB := float64(totalIn.Load()) / 1024 / 1024 + outMB := float64(totalOut.Load()) / 1024 / 1024 + + var saving string + if totalIn.Load() > 0 { + pct := 100.0 - (float64(totalOut.Load())/float64(totalIn.Load()))*100.0 + saving = fmt.Sprintf("%.1f%%", pct) + } else { + saving = "n/a" + } + + fmt.Printf("\n") + fmt.Printf(" ══════════════════════════════════════════════════════\n") + fmt.Printf(" Procesamiento completado en %s\n\n", elapsed) + fmt.Printf(" Imágenes %d total / %d exitosas / %d errores\n", + total, ok, errCount.Load()) + fmt.Printf(" Tamaño %.2f MB entrada → %.2f MB salida (%s reducción)\n", + inMB, outMB, saving) + fmt.Printf(" ══════════════════════════════════════════════════════\n\n") } +// ───────────────────────────────────────────────────────────────────────────── +// Procesado individual +// ───────────────────────────────────────────────────────────────────────────── + func processImage(inputPath string, cfg *Config) ProcessResult { + start := time.Now() + result := ProcessResult{ Filename: filepath.Base(inputPath), - Success: false, } - // Calcular ruta de salida - relPath, err := filepath.Rel(cfg.InputDir, inputPath) - if err != nil { - result.Error = fmt.Errorf("error calculando ruta relativa: %v", err) - return result + // Tamaño de entrada + if fi, err := os.Stat(inputPath); err == nil { + result.InputSize = fi.Size() } - outputPath := filepath.Join(cfg.OutputDir, relPath) + outputPath := buildOutputPath(inputPath, cfg) + result.OutputPath = outputPath - // Cambiar extensión si se especificó formato - if cfg.Format != "" { - ext := filepath.Ext(outputPath) - outputPath = outputPath[:len(outputPath)-len(ext)] + "." + cfg.Format - } - - // Crear subdirectorios si es necesario + // Crear directorios intermedios if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { result.Error = fmt.Errorf("error creando directorio: %v", err) return result @@ -303,7 +504,7 @@ func processImage(inputPath string, cfg *Config) ProcessResult { } defer f.Close() - img, format, err := image.Decode(f) + img, srcFormat, err := image.Decode(f) if err != nil { result.Error = fmt.Errorf("error decodificando imagen: %v", err) return result @@ -311,12 +512,12 @@ func processImage(inputPath string, cfg *Config) ProcessResult { if cfg.Verbose { bounds := img.Bounds() - log.Printf(" Formato original: %s, Dimensiones: %dx%d", format, bounds.Dx(), bounds.Dy()) + log.Printf(" %s: formato=%s tamaño=%dx%d", + result.Filename, srcFormat, bounds.Dx(), bounds.Dy()) } // Redimensionar - filter := getResampleFilter(cfg.Filter) - resized := imaging.Resize(img, cfg.Width, cfg.Height, filter) + resized := imaging.Resize(img, cfg.Width, cfg.Height, getResampleFilter(cfg.Filter)) // Guardar out, err := os.Create(outputPath) @@ -330,10 +531,10 @@ func processImage(inputPath string, cfg *Config) ProcessResult { } }() - // Determinar formato de salida outputFormat := cfg.Format if outputFormat == "" { - outputFormat = strings.ToLower(filepath.Ext(outputPath)[1:]) + ext := strings.ToLower(filepath.Ext(outputPath)) + outputFormat = strings.TrimPrefix(ext, ".") } switch outputFormat { @@ -341,10 +542,12 @@ func processImage(inputPath string, cfg *Config) ProcessResult { err = jpeg.Encode(out, resized, &jpeg.Options{Quality: cfg.Quality}) case "png": err = png.Encode(out, resized) - case "webp": - err = fmt.Errorf("escritura de WebP requiere CGO (use -formato jpg o png para convertir)") + case "tif", "tiff": + err = tiff.Encode(out, resized, &tiff.Options{Compression: tiff.Deflate, Predictor: true}) + case "webp", "bmp": + err = fmt.Errorf("formato %s sólo soportado en lectura — usa -formato jpg, png o tif", outputFormat) default: - err = imaging.Save(resized, outputPath) + err = fmt.Errorf("formato de salida desconocido: %s", outputFormat) } if err != nil { @@ -352,10 +555,20 @@ func processImage(inputPath string, cfg *Config) ProcessResult { return result } + // Tamaño de salida + if fi, err := os.Stat(outputPath); err == nil { + result.OutputSize = fi.Size() + } + result.Success = true + result.Duration = time.Since(start) return result } +// ───────────────────────────────────────────────────────────────────────────── +// Utilidades +// ───────────────────────────────────────────────────────────────────────────── + func getResampleFilter(name string) imaging.ResampleFilter { switch strings.ToLower(name) { case "lanczos": @@ -371,29 +584,206 @@ func getResampleFilter(name string) imaging.ResampleFilter { } } +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return "…" + s[len(s)-(max-1):] +} + +// ───────────────────────────────────────────────────────────────────────────── +// Sustitución de tokens en nombres de fichero +// ───────────────────────────────────────────────────────────────────────────── + +// applyStemReplacements aplica los tres modos de sustitución al stem del fichero. +// El orden de aplicación es: -replace → -replacei → -replacep. +func applyStemReplacements(stem string, cfg *Config) string { + if cfg.Replace != "" { + for _, p := range parsePairs(cfg.Replace) { + stem = strings.ReplaceAll(stem, p.From, p.To) + } + } + if cfg.ReplaceI != "" { + for _, p := range parsePairs(cfg.ReplaceI) { + stem = replaceInsensitive(stem, p.From, p.To, false) + } + } + if cfg.ReplaceP != "" { + for _, p := range parsePairs(cfg.ReplaceP) { + stem = replaceInsensitive(stem, p.From, p.To, true) + } + } + return stem +} + +// parsePairs descompone "orig1/dest1,orig2/dest2" en una slice de ReplacePair. +func parsePairs(raw string) []ReplacePair { + var pairs []ReplacePair + for _, item := range strings.Split(raw, ",") { + parts := strings.SplitN(strings.TrimSpace(item), "/", 2) + if len(parts) == 2 && parts[0] != "" { + pairs = append(pairs, ReplacePair{From: parts[0], To: parts[1]}) + } + } + return pairs +} + +// replaceInsensitive sustituye todas las ocurrencias de from en s de forma +// case-insensitive. Si preserveCase es true, la sustitución adapta las +// mayúsculas del token de destino para que reflejen las del token encontrado. +func replaceInsensitive(s, from, to string, preserveCase bool) string { + if from == "" { + return s + } + re := regexp.MustCompile(`(?i)` + regexp.QuoteMeta(from)) + return re.ReplaceAllStringFunc(s, func(match string) string { + if !preserveCase { + return to + } + return matchCase(match, to) + }) +} + +// matchCase aplica el patrón de mayúsculas de src a dst. +// +// "imagen" → "picture" (todo minúsculas → todo minúsculas) +// "Imagen" → "Picture" (primera mayúscula → primera mayúscula) +// "IMAGEN" → "PICTURE" (todo mayúsculas → todo mayúsculas) +// "iMaGeN" → "picture" (mixto → minúsculas, caso seguro) +func matchCase(src, dst string) string { + srcRunes := []rune(src) + dstRunes := []rune(dst) + if len(srcRunes) == 0 || len(dstRunes) == 0 { + return dst + } + + allUpper := true + allLower := true + for _, r := range srcRunes { + if unicode.IsLetter(r) { + if !unicode.IsUpper(r) { + allUpper = false + } + if !unicode.IsLower(r) { + allLower = false + } + } + } + + switch { + case allUpper: + return strings.ToUpper(dst) + case allLower: + return strings.ToLower(dst) + case unicode.IsUpper(srcRunes[0]): + // Primera letra mayúscula, resto minúsculas + out := make([]rune, len(dstRunes)) + for i, r := range dstRunes { + if i == 0 { + out[i] = unicode.ToUpper(r) + } else { + out[i] = unicode.ToLower(r) + } + } + return string(out) + default: + return strings.ToLower(dst) + } +} + func configurarAyuda() { flag.Usage = func() { - fmt.Printf("\n╔═══════════════════════════════════════════════════════════════╗\n") - fmt.Printf("║ ImgResizer - Redimensionador de Imágenes ║\n") - fmt.Printf("╚═══════════════════════════════════════════════════════════════╝\n\n") - fmt.Printf("Uso: resizer -dir -out [-width | -height] [opciones]\n\n") - fmt.Printf("Parámetros obligatorios:\n") - fmt.Printf(" -dir string\n") - fmt.Printf(" Directorio de origen con las imágenes\n") - fmt.Printf(" -out string\n") - fmt.Printf(" Directorio de destino para imágenes procesadas\n") - fmt.Printf(" -width int\n") - fmt.Printf(" Ancho objetivo (mantiene proporción si height no se especifica)\n") - fmt.Printf(" -height int\n") - fmt.Printf(" Alto objetivo (mantiene proporción si width no se especifica)\n\n") - fmt.Printf("Opciones:\n") - flag.PrintDefaults() - fmt.Printf("\nEjemplos:\n") - fmt.Printf(" # Redimensionar a 800px de ancho manteniendo proporción\n") - fmt.Printf(" resizer -dir ./input -out ./output -width 800\n\n") - fmt.Printf(" # Convertir todas las imágenes a JPG de 1920x1080\n") - fmt.Printf(" resizer -dir ./input -out ./output -width 1920 -height 1080 -formato jpg\n\n") - fmt.Printf(" # Procesar con 8 workers y mantener estructura de carpetas\n") - fmt.Printf(" resizer -dir ./input -out ./output -width 1024 -workers 8 -estructura\n\n") + fmt.Printf(` +██╗███╗ ███╗ █████╗ ██████╗ ██╗███╗ ██╗ █████╗ ████████╗ ██████╗ ██████╗ +██║████╗ ████║██╔══██╗██╔════╝ ██║████╗ ██║██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗ +██║██╔████╔██║███████║██║ ███╗██║██╔██╗ ██║███████║ ██║ ██║ ██║██████╔╝ +██║██║╚██╔╝██║██╔══██║██║ ██║██║██║╚██╗██║██╔══██║ ██║ ██║ ██║██╔══██╗ +██║██║ ╚═╝ ██║██║ ██║╚██████╔╝██║██║ ╚████║██║ ██║ ██║ ╚██████╔╝██║ ██║ +╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ + + IMAGINATOR VER 2.4.0 Juan V. Navarro Leal +────────────────────────────────────────────────────────────────────────────── + ActiveThing activething.com/Imaginator ©2026 ActiveThing +────────────────────────────────────────────────────────────────────────────── + +USO + imaginator -dir -out { -width N | -height N } [opciones] + +PARÁMETROS OBLIGATORIOS + -dir string Directorio de origen con las imágenes + -out string Directorio de destino para imágenes procesadas + + Al menos uno de los siguientes: + -width int Ancho objetivo (mantiene proporción si -height no se especifica) + -height int Alto objetivo (mantiene proporción si -width no se especifica) + +FORMATOS SOPORTADOS + Lectura jpg · jpeg · png · tif · tiff · bmp · webp + Escritura jpg · png · tif + (bmp y webp son sólo lectura — usa -formato jpg, png o tif para convertir) + +OPCIONES DE IMAGEN + -calidad int Calidad JPEG 1–100 [85] + -formato string Formato de salida: jpg, png, tif [mantener original] + -filtro string Filtro: lanczos · cubic · linear · box [lanczos] + +OPCIONES DE NOMBRADO + -prefijo string Prefijo para el nombre de salida [""] + -sufijo string Sufijo para el nombre de salida [""] + -lowercase Normalizar nombre de salida a minúsculas [false] + -uppercase Normalizar nombre de salida a mayúsculas [false] + -replace string Sustituir token en nombre: orig/dest[,orig2/dest2] + case sensitive: imagen→picture, Imagen→Imagen + -replacei string Igual que -replace pero case insensitive + imagen→picture, Imagen→picture, IMAGEN→picture + -replacep string Igual que -replacei pero preserva capitalización + imagen→picture, Imagen→Picture, IMAGEN→PICTURE + +OPCIONES DE FILTRADO + -filter string Glob sobre el nombre del fichero a procesar [""] + ej: "foto_*.jpg" "DSC_????.png" "*.webp" + +OPCIONES DE EJECUCIÓN + -workers int Workers concurrentes [4] + -estructura Mantener estructura de subdirectorios [false] + -dry-run Mostrar qué se procesaría sin ejecutar [false] + -verbose Información detallada por imagen [false] + +EJEMPLOS + # Redimensionar a 800 px de ancho, mantener proporción + imaginator -dir ./fotos -out ./thumbs -width 800 + + # Convertir todo a JPG 1920×1080 con calidad 90 + imaginator -dir ./raw -out ./web -width 1920 -height 1080 -formato jpg -calidad 90 + + # 8 workers, preservar árbol de carpetas, calidad 85 + imaginator -dir ./raw -out ./web -width 1024 -workers 8 -estructura -calidad 85 + + # Procesar en el mismo directorio añadiendo sufijo "_800w" + imaginator -dir ./fotos -out ./fotos -width 800 -sufijo _800w + + # Procesar sólo ficheros que coincidan con un glob + imaginator -dir ./raw -out ./web -width 1920 -filter "DSC_*.jpg" + + # Renombrar tokens en los ficheros de salida (case sensitive) + imaginator -dir ./raw -out ./web -width 800 -replace "imagen/picture,foto/photo" + + # Renombrar preservando capitalización + imaginator -dir ./raw -out ./web -width 800 -replacep "imagen/picture" + # imagen_001.jpg → picture_001.jpg + # Imagen_001.jpg → Picture_001.jpg + # IMAGEN_001.jpg → PICTURE_001.jpg + + # Normalizar todos los nombres de salida a minúsculas + imaginator -dir ./raw -out ./web -width 800 -lowercase + + # Normalizar lote mixto (jpg+png+webp) todo a jpg + imaginator -dir ./raw -out ./web -width 1920 -formato jpg + + ───────────────────────────────────────────────────────────────────────────── + "No deseo caminar sobre el agua." — Hermann Hesse, Siddhartha + ───────────────────────────────────────────────────────────────────────────── + +`, version) } } diff --git a/main.go12 b/main.go12 new file mode 100644 index 0000000..25a9a8a --- /dev/null +++ b/main.go12 @@ -0,0 +1,399 @@ +/* + —————————————————————————————————————————————————————————————————————————— + Proyecto ImgResizer + +————————————————————————————————————————————————————————————————————————————— + + Fichero main.go2 + Package ImgResizer + Autor Juan V. Navarro juanvnl@activething.com + Creado 26/01/2026 + Mejorado 26/01/2026 + +————————————————————————————————————————————————————————————————————————————— + + LICENSES AND TERMS OF USE + ------------------------- + + This software is licensed under the Elastic License v2.0 (the "License"). + For full terms and additional information regarding permitted and prohibited + uses, please visit: + https://activething.com/ATGO/licenses + + You may use, copy, modify, and redistribute this software internally within + your organization for any purpose, including research, development, and + testing, subject to the terms of this License. + + You may NOT, however, use, provide, distribute, or make this software + available to any third party as part of a hosted service, SaaS offering, or + commercial product without first obtaining a commercial license from + Active Thing. + + You may combine this software with other code, provided that such + combination does not circumvent the restrictions of this License. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. In no event shall + the authors or copyright holders be liable for any claim, damages, or other + liability arising from the use of this software. + +————————————————————————————————————————————————————————————————————————————— + + Web : activething.com | activething.com/ImgResizer + git : g.activething.com | github.com/activething/ImgResizer + Correo : dev@activething.com + +————————————————————————————————————————————————————————————————————————————— + + No deseo caminar sobre el agua", dijo Siddhartha. + Que los antiguos chamanes se contenten con tales habilidades. + —— Hermann Hesse, Siddhartha + +————————————————————————————————————————————————————————————————————————————— + + Copyright (c) 2026 Active Thing + +————————————————————————————————————————————————————————————————————————————— +*/ +package main + +import ( + "flag" + "fmt" + "image" + "image/jpeg" + "image/png" + "log" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + + "github.com/disintegration/imaging" + _ "golang.org/x/image/webp" +) + +type Config struct { + InputDir string + OutputDir string + Width int + Height int + Quality int + Format string + Filter string + Workers int + Verbose bool + KeepStructure bool +} + +type ProcessResult struct { + Filename string + Success bool + Error error +} + +func main() { + cfg := parseFlags() + + if err := validateConfig(cfg); err != nil { + log.Fatal(err) + } + + if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { + log.Fatalf("Error creando directorio de salida: %v", err) + } + + files, err := collectImageFiles(cfg.InputDir, cfg.KeepStructure) + if err != nil { + log.Fatalf("Error leyendo directorio: %v", err) + } + + if len(files) == 0 { + log.Println("No se encontraron imágenes para procesar") + return + } + + fmt.Printf("Encontradas %d imágenes. Procesando con %d workers...\n\n", len(files), cfg.Workers) + + processImages(files, cfg) +} + +func parseFlags() *Config { + cfg := &Config{} + + flag.StringVar(&cfg.InputDir, "dir", "", "Directorio de origen") + flag.StringVar(&cfg.OutputDir, "out", "", "Directorio de destino") + flag.IntVar(&cfg.Width, "width", 0, "Ancho objetivo (mantiene proporción si height=0)") + flag.IntVar(&cfg.Height, "height", 0, "Alto objetivo (mantiene proporción si width=0)") + flag.IntVar(&cfg.Quality, "calidad", 100, "Calidad JPEG (1-100)") + flag.StringVar(&cfg.Format, "formato", "", "Formato de salida: jpg, png (vacío=mantener original)") + flag.StringVar(&cfg.Filter, "filtro", "lanczos", "Filtro de redimensionado: lanczos, cubic, linear, box") + flag.IntVar(&cfg.Workers, "workers", 4, "Número de workers concurrentes") + flag.BoolVar(&cfg.Verbose, "verbose", false, "Mostrar información detallada") + flag.BoolVar(&cfg.KeepStructure, "estructura", false, "Mantener estructura de subdirectorios") + + configurarAyuda() + flag.Parse() + + return cfg +} + +func validateConfig(cfg *Config) error { + if cfg.InputDir == "" || cfg.OutputDir == "" { + flag.Usage() + return fmt.Errorf("faltan parámetros obligatorios") + } + + if cfg.Width == 0 && cfg.Height == 0 { + return fmt.Errorf("debe especificar al menos -width o -height") + } + + if cfg.Width < 0 || cfg.Height < 0 { + return fmt.Errorf("las dimensiones no pueden ser negativas") + } + + if cfg.Quality < 1 || cfg.Quality > 100 { + return fmt.Errorf("la calidad debe estar entre 1 y 100") + } + + if cfg.Format != "" && cfg.Format != "jpg" && cfg.Format != "jpeg" && cfg.Format != "png" { + return fmt.Errorf("formato no soportado: %s (use jpg o png)", cfg.Format) + } + + absInput, err := filepath.Abs(cfg.InputDir) + if err != nil { + return fmt.Errorf("error resolviendo ruta de entrada: %v", err) + } + + absOutput, err := filepath.Abs(cfg.OutputDir) + if err != nil { + return fmt.Errorf("error resolviendo ruta de salida: %v", err) + } + + if absInput == absOutput { + return fmt.Errorf("el directorio de entrada y salida no pueden ser el mismo") + } + + if _, err := os.Stat(cfg.InputDir); os.IsNotExist(err) { + return fmt.Errorf("el directorio de entrada no existe: %s", cfg.InputDir) + } + + if cfg.Workers < 1 { + cfg.Workers = 1 + } + + return nil +} + +func collectImageFiles(root string, keepStructure bool) ([]string, error) { + var files []string + validExts := map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".webp": true, + } + + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + ext := strings.ToLower(filepath.Ext(path)) + if validExts[ext] { + files = append(files, path) + } + + return nil + }) + + return files, err +} + +func processImages(files []string, cfg *Config) { + var ( + wg sync.WaitGroup + processed atomic.Int32 + errors atomic.Int32 + total = int32(len(files)) + ) + + jobs := make(chan string, len(files)) + results := make(chan ProcessResult, len(files)) + + // Iniciar workers + for i := 0; i < cfg.Workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for inputPath := range jobs { + result := processImage(inputPath, cfg) + results <- result + + current := processed.Add(1) + if result.Success { + if cfg.Verbose { + fmt.Printf("[%d/%d] ✓ %s\n", current, total, result.Filename) + } + } else { + errors.Add(1) + fmt.Printf("[%d/%d] ✗ %s: %v\n", current, total, result.Filename, result.Error) + } + } + }() + } + + // Enviar trabajos + for _, file := range files { + jobs <- file + } + close(jobs) + + // Esperar completar + wg.Wait() + close(results) + + // Resumen final + fmt.Printf("\n═══════════════════════════════════════════════════════\n") + fmt.Printf("Procesamiento completado:\n") + fmt.Printf(" Total: %d imágenes\n", total) + fmt.Printf(" Exitosas: %d\n", total-errors.Load()) + fmt.Printf(" Errores: %d\n", errors.Load()) + fmt.Printf("═══════════════════════════════════════════════════════\n") +} + +func processImage(inputPath string, cfg *Config) ProcessResult { + result := ProcessResult{ + Filename: filepath.Base(inputPath), + Success: false, + } + + // Calcular ruta de salida + relPath, err := filepath.Rel(cfg.InputDir, inputPath) + if err != nil { + result.Error = fmt.Errorf("error calculando ruta relativa: %v", err) + return result + } + + outputPath := filepath.Join(cfg.OutputDir, relPath) + + // Cambiar extensión si se especificó formato + if cfg.Format != "" { + ext := filepath.Ext(outputPath) + outputPath = outputPath[:len(outputPath)-len(ext)] + "." + cfg.Format + } + + // Crear subdirectorios si es necesario + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + result.Error = fmt.Errorf("error creando directorio: %v", err) + return result + } + + // Abrir imagen + f, err := os.Open(inputPath) + if err != nil { + result.Error = fmt.Errorf("error abriendo archivo: %v", err) + return result + } + defer f.Close() + + img, format, err := image.Decode(f) + if err != nil { + result.Error = fmt.Errorf("error decodificando imagen: %v", err) + return result + } + + if cfg.Verbose { + bounds := img.Bounds() + log.Printf(" Formato original: %s, Dimensiones: %dx%d", format, bounds.Dx(), bounds.Dy()) + } + + // Redimensionar + filter := getResampleFilter(cfg.Filter) + resized := imaging.Resize(img, cfg.Width, cfg.Height, filter) + + // Guardar + out, err := os.Create(outputPath) + if err != nil { + result.Error = fmt.Errorf("error creando archivo de salida: %v", err) + return result + } + defer func() { + if cerr := out.Close(); cerr != nil && result.Error == nil { + result.Error = cerr + } + }() + + // Determinar formato de salida + outputFormat := cfg.Format + if outputFormat == "" { + outputFormat = strings.ToLower(filepath.Ext(outputPath)[1:]) + } + + switch outputFormat { + case "jpg", "jpeg": + err = jpeg.Encode(out, resized, &jpeg.Options{Quality: cfg.Quality}) + case "png": + err = png.Encode(out, resized) + case "webp": + err = fmt.Errorf("escritura de WebP requiere CGO (use -formato jpg o png para convertir)") + default: + err = imaging.Save(resized, outputPath) + } + + if err != nil { + result.Error = fmt.Errorf("error guardando imagen: %v", err) + return result + } + + result.Success = true + return result +} + +func getResampleFilter(name string) imaging.ResampleFilter { + switch strings.ToLower(name) { + case "lanczos": + return imaging.Lanczos + case "cubic": + return imaging.CatmullRom + case "linear": + return imaging.Linear + case "box": + return imaging.Box + default: + return imaging.Lanczos + } +} + +func configurarAyuda() { + flag.Usage = func() { + fmt.Printf("\n╔═══════════════════════════════════════════════════════════════╗\n") + fmt.Printf("║ ImgResizer - Redimensionador de Imágenes ║\n") + fmt.Printf("╚═══════════════════════════════════════════════════════════════╝\n\n") + fmt.Printf("Uso: resizer -dir -out [-width | -height] [opciones]\n\n") + fmt.Printf("Parámetros obligatorios:\n") + fmt.Printf(" -dir string\n") + fmt.Printf(" Directorio de origen con las imágenes\n") + fmt.Printf(" -out string\n") + fmt.Printf(" Directorio de destino para imágenes procesadas\n") + fmt.Printf(" -width int\n") + fmt.Printf(" Ancho objetivo (mantiene proporción si height no se especifica)\n") + fmt.Printf(" -height int\n") + fmt.Printf(" Alto objetivo (mantiene proporción si width no se especifica)\n\n") + fmt.Printf("Opciones:\n") + flag.PrintDefaults() + fmt.Printf("\nEjemplos:\n") + fmt.Printf(" # Redimensionar a 800px de ancho manteniendo proporción\n") + fmt.Printf(" resizer -dir ./input -out ./output -width 800\n\n") + fmt.Printf(" # Convertir todas las imágenes a JPG de 1920x1080\n") + fmt.Printf(" resizer -dir ./input -out ./output -width 1920 -height 1080 -formato jpg\n\n") + fmt.Printf(" # Procesar con 8 workers y mantener estructura de carpetas\n") + fmt.Printf(" resizer -dir ./input -out ./output -width 1024 -workers 8 -estructura\n\n") + } +}