master
dev 7 months ago
parent 615b27dd41
commit 1275b6cc50

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

@ -1,16 +1,16 @@
/* /*
—————————————————————————————————————————————————————————————————————————— ——————————————————————————————————————————————————————————————————————————
Proyecto ImgResizer Proyecto Imaginator
————————————————————————————————————————————————————————————————————————————— —————————————————————————————————————————————————————————————————————————
Fichero main.go Fichero main.go
Package ImgResizer Package Imaginator
Autor Juan V. Navarro juanvnl@activething.com Autor Juan V. Navarro juanvnl@activething.com
Creado 26/01/2026 Creado 26/01/2026
Mejorado 26/01/2026 Mejorado 20/03/2026
————————————————————————————————————————————————————————————————————————————— —————————————————————————————————————————————————————————————————————————
LICENSES AND TERMS OF USE LICENSES AND TERMS OF USE
------------------------- -------------------------
@ -29,32 +29,23 @@
commercial product without first obtaining a commercial license from commercial product without first obtaining a commercial license from
Active Thing. 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 Web : activething.com | activething.com/Imaginator
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, git : g.activething.com | github.com/activething/Imaginator
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 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. Que los antiguos chamanes se contenten con tales habilidades.
—— Hermann Hesse, Siddhartha —— Hermann Hesse, Siddhartha
————————————————————————————————————————————————————————————————————————————— —————————————————————————————————————————————————————————————————————————
Copyright (c) 2026 Active Thing Copyright (c) 2026 Active Thing
————————————————————————————————————————————————————————————————————————————— —————————————————————————————————————————————————————————————————————————
*/ */
package main package main
@ -67,14 +58,25 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"unicode"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
_ "golang.org/x/image/bmp"
"golang.org/x/image/tiff"
_ "golang.org/x/image/webp" _ "golang.org/x/image/webp"
) )
const version = "2.3.0"
// ─────────────────────────────────────────────────────────────────────────────
// Tipos
// ─────────────────────────────────────────────────────────────────────────────
type Config struct { type Config struct {
InputDir string InputDir string
OutputDir string OutputDir string
@ -86,14 +88,43 @@ type Config struct {
Workers int Workers int
Verbose bool Verbose bool
KeepStructure 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 { type ProcessResult struct {
Filename string Filename string
OutputPath string
Success bool Success bool
Error error 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() { func main() {
cfg := parseFlags() cfg := parseFlags()
@ -105,34 +136,78 @@ func main() {
log.Fatalf("Error creando directorio de salida: %v", err) 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 { if err != nil {
log.Fatalf("Error leyendo directorio: %v", err) log.Fatalf("Error leyendo directorio: %v", err)
} }
if len(files) == 0 { if len(summary.Files) == 0 {
log.Println("No se encontraron imágenes para procesar") fmt.Println(" No se encontraron imágenes para procesar.")
return 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 { func parseFlags() *Config {
cfg := &Config{} cfg := &Config{}
flag.StringVar(&cfg.InputDir, "dir", "", "Directorio de origen") flag.StringVar(&cfg.InputDir, "dir", "", "Directorio de origen")
flag.StringVar(&cfg.OutputDir, "out", "", "Directorio de destino") 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.Width, "width", 0, "Ancho objetivo")
flag.IntVar(&cfg.Height, "height", 0, "Alto objetivo (mantiene proporción si width=0)") flag.IntVar(&cfg.Height, "height", 0, "Alto objetivo")
flag.IntVar(&cfg.Quality, "calidad", 100, "Calidad JPEG (1-100)") flag.IntVar(&cfg.Quality, "calidad", 85, "Calidad JPEG (1-100)")
flag.StringVar(&cfg.Format, "formato", "", "Formato de salida: jpg, png (vacío=mantener original)") flag.StringVar(&cfg.Format, "formato", "", "Formato de salida: jpg, png, tif")
flag.StringVar(&cfg.Filter, "filtro", "lanczos", "Filtro de redimensionado: lanczos, cubic, linear, box") flag.StringVar(&cfg.Filter, "filtro", "lanczos", "Filtro: lanczos, cubic, linear, box")
flag.IntVar(&cfg.Workers, "workers", 4, "Número de workers concurrentes") flag.IntVar(&cfg.Workers, "workers", 4, "Workers concurrentes")
flag.BoolVar(&cfg.Verbose, "verbose", false, "Mostrar información detallada") flag.BoolVar(&cfg.Verbose, "verbose", false, "Información detallada")
flag.BoolVar(&cfg.KeepStructure, "estructura", false, "Mantener estructura de subdirectorios") 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() configurarAyuda()
flag.Parse() flag.Parse()
@ -143,7 +218,7 @@ func parseFlags() *Config {
func validateConfig(cfg *Config) error { func validateConfig(cfg *Config) error {
if cfg.InputDir == "" || cfg.OutputDir == "" { if cfg.InputDir == "" || cfg.OutputDir == "" {
flag.Usage() 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 { 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") return fmt.Errorf("la calidad debe estar entre 1 y 100")
} }
if cfg.Format != "" && cfg.Format != "jpg" && cfg.Format != "jpeg" && cfg.Format != "png" { if cfg.Format != "" &&
return fmt.Errorf("formato no soportado: %s (use jpg o png)", 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) 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) return fmt.Errorf("error resolviendo ruta de salida: %v", err)
} }
if absInput == absOutput { if absInput == absOutput && cfg.Prefix == "" && cfg.Suffix == "" {
return fmt.Errorf("el directorio de entrada y salida no pueden ser el mismo") 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) { if _, err := os.Stat(cfg.InputDir); os.IsNotExist(err) {
@ -184,112 +264,233 @@ func validateConfig(cfg *Config) error {
cfg.Workers = 1 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 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{ validExts := map[string]bool{
".jpg": true, ".jpg": true,
".jpeg": true, ".jpeg": true,
".png": true, ".png": true,
".webp": 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 { err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
if info.IsDir() { if info.IsDir() {
return nil return nil
} }
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))
if validExts[ext] { if !validExts[ext] {
files = append(files, path) 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 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) { func processImages(files []string, cfg *Config) {
var ( var (
wg sync.WaitGroup wg sync.WaitGroup
mu sync.Mutex
processed atomic.Int32 processed atomic.Int32
errors atomic.Int32 errCount atomic.Int32
totalIn atomic.Int64
totalOut atomic.Int64
total = int32(len(files)) total = int32(len(files))
start = time.Now()
) )
jobs := make(chan string, len(files)) jobs := make(chan string, len(files))
results := make(chan ProcessResult, len(files))
// Iniciar workers
for i := 0; i < cfg.Workers; i++ { for i := 0; i < cfg.Workers; i++ {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
for inputPath := range jobs { for inputPath := range jobs {
result := processImage(inputPath, cfg) result := processImage(inputPath, cfg)
results <- result
current := processed.Add(1) current := processed.Add(1)
mu.Lock()
if result.Success { if result.Success {
totalIn.Add(result.InputSize)
totalOut.Add(result.OutputSize)
if cfg.Verbose { 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 { } else {
errors.Add(1) errCount.Add(1)
fmt.Printf("[%d/%d] ✗ %s: %v\n", current, total, result.Filename, result.Error) fmt.Printf(" [%d/%d] ✗ %s: %v\n", current, total, result.Filename, result.Error)
} }
mu.Unlock()
} }
}() }()
} }
// Enviar trabajos for _, f := range files {
for _, file := range files { jobs <- f
jobs <- file
} }
close(jobs) close(jobs)
// Esperar completar
wg.Wait() wg.Wait()
close(results)
elapsed := time.Since(start).Round(time.Millisecond)
// Resumen final ok := total - errCount.Load()
fmt.Printf("\n═══════════════════════════════════════════════════════\n") inMB := float64(totalIn.Load()) / 1024 / 1024
fmt.Printf("Procesamiento completado:\n") outMB := float64(totalOut.Load()) / 1024 / 1024
fmt.Printf(" Total: %d imágenes\n", total)
fmt.Printf(" Exitosas: %d\n", total-errors.Load()) var saving string
fmt.Printf(" Errores: %d\n", errors.Load()) if totalIn.Load() > 0 {
fmt.Printf("═══════════════════════════════════════════════════════\n") 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 { func processImage(inputPath string, cfg *Config) ProcessResult {
start := time.Now()
result := ProcessResult{ result := ProcessResult{
Filename: filepath.Base(inputPath), Filename: filepath.Base(inputPath),
Success: false,
} }
// Calcular ruta de salida // Tamaño de entrada
relPath, err := filepath.Rel(cfg.InputDir, inputPath) if fi, err := os.Stat(inputPath); err == nil {
if err != nil { result.InputSize = fi.Size()
result.Error = fmt.Errorf("error calculando ruta relativa: %v", err)
return result
} }
outputPath := filepath.Join(cfg.OutputDir, relPath) outputPath := buildOutputPath(inputPath, cfg)
result.OutputPath = outputPath
// Cambiar extensión si se especificó formato // Crear directorios intermedios
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 { if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
result.Error = fmt.Errorf("error creando directorio: %v", err) result.Error = fmt.Errorf("error creando directorio: %v", err)
return result return result
@ -303,7 +504,7 @@ func processImage(inputPath string, cfg *Config) ProcessResult {
} }
defer f.Close() defer f.Close()
img, format, err := image.Decode(f) img, srcFormat, err := image.Decode(f)
if err != nil { if err != nil {
result.Error = fmt.Errorf("error decodificando imagen: %v", err) result.Error = fmt.Errorf("error decodificando imagen: %v", err)
return result return result
@ -311,12 +512,12 @@ func processImage(inputPath string, cfg *Config) ProcessResult {
if cfg.Verbose { if cfg.Verbose {
bounds := img.Bounds() 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 // Redimensionar
filter := getResampleFilter(cfg.Filter) resized := imaging.Resize(img, cfg.Width, cfg.Height, getResampleFilter(cfg.Filter))
resized := imaging.Resize(img, cfg.Width, cfg.Height, filter)
// Guardar // Guardar
out, err := os.Create(outputPath) out, err := os.Create(outputPath)
@ -330,10 +531,10 @@ func processImage(inputPath string, cfg *Config) ProcessResult {
} }
}() }()
// Determinar formato de salida
outputFormat := cfg.Format outputFormat := cfg.Format
if outputFormat == "" { if outputFormat == "" {
outputFormat = strings.ToLower(filepath.Ext(outputPath)[1:]) ext := strings.ToLower(filepath.Ext(outputPath))
outputFormat = strings.TrimPrefix(ext, ".")
} }
switch outputFormat { switch outputFormat {
@ -341,10 +542,12 @@ func processImage(inputPath string, cfg *Config) ProcessResult {
err = jpeg.Encode(out, resized, &jpeg.Options{Quality: cfg.Quality}) err = jpeg.Encode(out, resized, &jpeg.Options{Quality: cfg.Quality})
case "png": case "png":
err = png.Encode(out, resized) err = png.Encode(out, resized)
case "webp": case "tif", "tiff":
err = fmt.Errorf("escritura de WebP requiere CGO (use -formato jpg o png para convertir)") 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: default:
err = imaging.Save(resized, outputPath) err = fmt.Errorf("formato de salida desconocido: %s", outputFormat)
} }
if err != nil { if err != nil {
@ -352,10 +555,20 @@ func processImage(inputPath string, cfg *Config) ProcessResult {
return result return result
} }
// Tamaño de salida
if fi, err := os.Stat(outputPath); err == nil {
result.OutputSize = fi.Size()
}
result.Success = true result.Success = true
result.Duration = time.Since(start)
return result return result
} }
// ─────────────────────────────────────────────────────────────────────────────
// Utilidades
// ─────────────────────────────────────────────────────────────────────────────
func getResampleFilter(name string) imaging.ResampleFilter { func getResampleFilter(name string) imaging.ResampleFilter {
switch strings.ToLower(name) { switch strings.ToLower(name) {
case "lanczos": 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() { func configurarAyuda() {
flag.Usage = func() { flag.Usage = func() {
fmt.Printf("\n╔═══════════════════════════════════════════════════════════════╗\n") fmt.Printf(`
fmt.Printf("║ ImgResizer - Redimensionador de Imágenes ║\n") ██╗███╗ ███╗ █████╗ ██████╗ ██╗███╗ ██╗ █████╗ ████████╗ ██████╗ ██████╗
fmt.Printf("╚═══════════════════════════════════════════════════════════════╝\n\n") ██║████╗ ████║██╔══██╗██╔════╝ ██║████╗ ██║██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗
fmt.Printf("Uso: resizer -dir <origen> -out <destino> [-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") IMAGINATOR VER 2.4.0 Juan V. Navarro Leal
fmt.Printf(" -width int\n") ──────────────────────────────────────────────────────────────────────────────
fmt.Printf(" Ancho objetivo (mantiene proporción si height no se especifica)\n") ActiveThing activething.com/Imaginator ©2026 ActiveThing
fmt.Printf(" -height int\n") ──────────────────────────────────────────────────────────────────────────────
fmt.Printf(" Alto objetivo (mantiene proporción si width no se especifica)\n\n")
fmt.Printf("Opciones:\n") USO
flag.PrintDefaults() imaginator -dir <origen> -out <destino> { -width N | -height N } [opciones]
fmt.Printf("\nEjemplos:\n")
fmt.Printf(" # Redimensionar a 800px de ancho manteniendo proporción\n") PARÁMETROS OBLIGATORIOS
fmt.Printf(" resizer -dir ./input -out ./output -width 800\n\n") -dir string Directorio de origen con las imágenes
fmt.Printf(" # Convertir todas las imágenes a JPG de 1920x1080\n") -out string Directorio de destino para imágenes procesadas
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") Al menos uno de los siguientes:
fmt.Printf(" resizer -dir ./input -out ./output -width 1024 -workers 8 -estructura\n\n") -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)
} }
} }

@ -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 <origen> -out <destino> [-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")
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.