|
|
/*
|
|
|
——————————————————————————————————————————————————————————————————————————
|
|
|
Proyecto Imaginator
|
|
|
|
|
|
—————————————————————————————————————————————————————————————————————————
|
|
|
|
|
|
Fichero main.go
|
|
|
Package Imaginator
|
|
|
Autor Juan V. Navarro juanvnl@activething.com
|
|
|
Creado 26/01/2026
|
|
|
Mejorado 20/03/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.
|
|
|
|
|
|
—————————————————————————————————————————————————————————————————————————
|
|
|
|
|
|
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.
|
|
|
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"
|
|
|
"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
|
|
|
Width int
|
|
|
Height int
|
|
|
Quality int
|
|
|
Format string
|
|
|
Filter string
|
|
|
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
|
|
|
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()
|
|
|
|
|
|
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)
|
|
|
}
|
|
|
|
|
|
summary, err := collectImageFiles(cfg.InputDir, cfg.FileFilter)
|
|
|
if err != nil {
|
|
|
log.Fatalf("Error leyendo directorio: %v", err)
|
|
|
}
|
|
|
|
|
|
if len(summary.Files) == 0 {
|
|
|
fmt.Println(" No se encontraron imágenes para procesar.")
|
|
|
return
|
|
|
}
|
|
|
|
|
|
// 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
|
|
|
}
|
|
|
|
|
|
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")
|
|
|
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()
|
|
|
|
|
|
return cfg
|
|
|
}
|
|
|
|
|
|
func validateConfig(cfg *Config) error {
|
|
|
if cfg.InputDir == "" || cfg.OutputDir == "" {
|
|
|
flag.Usage()
|
|
|
return fmt.Errorf("faltan parámetros obligatorios: -dir y -out son requeridos")
|
|
|
}
|
|
|
|
|
|
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" &&
|
|
|
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)
|
|
|
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 && 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) {
|
|
|
return fmt.Errorf("el directorio de entrada no existe: %s", cfg.InputDir)
|
|
|
}
|
|
|
|
|
|
if 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
|
|
|
}
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
// 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] {
|
|
|
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 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
|
|
|
errCount atomic.Int32
|
|
|
totalIn atomic.Int64
|
|
|
totalOut atomic.Int64
|
|
|
total = int32(len(files))
|
|
|
start = time.Now()
|
|
|
)
|
|
|
|
|
|
jobs := make(chan string, len(files))
|
|
|
|
|
|
for i := 0; i < cfg.Workers; i++ {
|
|
|
wg.Add(1)
|
|
|
go func() {
|
|
|
defer wg.Done()
|
|
|
for inputPath := range jobs {
|
|
|
result := processImage(inputPath, cfg)
|
|
|
current := processed.Add(1)
|
|
|
|
|
|
mu.Lock()
|
|
|
if result.Success {
|
|
|
totalIn.Add(result.InputSize)
|
|
|
totalOut.Add(result.OutputSize)
|
|
|
if cfg.Verbose {
|
|
|
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 {
|
|
|
errCount.Add(1)
|
|
|
fmt.Printf(" [%d/%d] ✗ %s: %v\n", current, total, result.Filename, result.Error)
|
|
|
}
|
|
|
mu.Unlock()
|
|
|
}
|
|
|
}()
|
|
|
}
|
|
|
|
|
|
for _, f := range files {
|
|
|
jobs <- f
|
|
|
}
|
|
|
close(jobs)
|
|
|
wg.Wait()
|
|
|
|
|
|
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),
|
|
|
}
|
|
|
|
|
|
// Tamaño de entrada
|
|
|
if fi, err := os.Stat(inputPath); err == nil {
|
|
|
result.InputSize = fi.Size()
|
|
|
}
|
|
|
|
|
|
outputPath := buildOutputPath(inputPath, cfg)
|
|
|
result.OutputPath = outputPath
|
|
|
|
|
|
// Crear directorios intermedios
|
|
|
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, srcFormat, 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(" %s: formato=%s tamaño=%dx%d",
|
|
|
result.Filename, srcFormat, bounds.Dx(), bounds.Dy())
|
|
|
}
|
|
|
|
|
|
// Redimensionar
|
|
|
resized := imaging.Resize(img, cfg.Width, cfg.Height, getResampleFilter(cfg.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
|
|
|
}
|
|
|
}()
|
|
|
|
|
|
outputFormat := cfg.Format
|
|
|
if outputFormat == "" {
|
|
|
ext := strings.ToLower(filepath.Ext(outputPath))
|
|
|
outputFormat = strings.TrimPrefix(ext, ".")
|
|
|
}
|
|
|
|
|
|
switch outputFormat {
|
|
|
case "jpg", "jpeg":
|
|
|
err = jpeg.Encode(out, resized, &jpeg.Options{Quality: cfg.Quality})
|
|
|
case "png":
|
|
|
err = png.Encode(out, resized)
|
|
|
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 = fmt.Errorf("formato de salida desconocido: %s", outputFormat)
|
|
|
}
|
|
|
|
|
|
if err != nil {
|
|
|
result.Error = fmt.Errorf("error guardando imagen: %v", err)
|
|
|
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":
|
|
|
return imaging.Lanczos
|
|
|
case "cubic":
|
|
|
return imaging.CatmullRom
|
|
|
case "linear":
|
|
|
return imaging.Linear
|
|
|
case "box":
|
|
|
return imaging.Box
|
|
|
default:
|
|
|
return imaging.Lanczos
|
|
|
}
|
|
|
}
|
|
|
|
|
|
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(`
|
|
|
██╗███╗ ███╗ █████╗ ██████╗ ██╗███╗ ██╗ █████╗ ████████╗ ██████╗ ██████╗
|
|
|
██║████╗ ████║██╔══██╗██╔════╝ ██║████╗ ██║██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗
|
|
|
██║██╔████╔██║███████║██║ ███╗██║██╔██╗ ██║███████║ ██║ ██║ ██║██████╔╝
|
|
|
██║██║╚██╔╝██║██╔══██║██║ ██║██║██║╚██╗██║██╔══██║ ██║ ██║ ██║██╔══██╗
|
|
|
██║██║ ╚═╝ ██║██║ ██║╚██████╔╝██║██║ ╚████║██║ ██║ ██║ ╚██████╔╝██║ ██║
|
|
|
╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
|
|
|
|
|
|
IMAGINATOR VER 2.4.0 Juan V. Navarro Leal
|
|
|
──────────────────────────────────────────────────────────────────────────────
|
|
|
ActiveThing activething.com/Imaginator ©2026 ActiveThing
|
|
|
──────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
USO
|
|
|
imaginator -dir <origen> -out <destino> { -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)
|
|
|
}
|
|
|
}
|