You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

950 lines
35 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/*
——————————————————————————————————————————————————————————————————————————
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 (
"bytes"
"encoding/base64"
"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.6.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 // normalize output filename to lowercase
Uppercase bool // normalize output filename to uppercase
B64Ext string // if set, encode output as base64 with this extension ("" = disabled, "b64" = default)
B64Enabled bool // true when -b64 flag was provided
TSFile string // if set, write a TypeScript file with all images as base64 data URIs
NoSpaces string // replacement char for spaces in output filename ("" = disabled, "_" = default)
NoSpacesOn bool // true when -nospaces flag was provided
}
// 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
B64Data string // populated when B64Enabled or TSFile is set: "data:image/png;base64,..."
ImageKey string // stem used as key in the TS file
}
type ImageSummary struct {
Files []string
ByExt map[string]int
ReadOnlyExts []string // read-only formats present in the batch
}
// ─────────────────────────────────────────────────────────────────────────────
// 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 creating output directory: %v", err)
}
summary, err := collectImageFiles(cfg.InputDir, cfg.FileFilter)
if err != nil {
log.Fatalf("error reading source directory: %v", err)
}
if len(summary.Files) == 0 {
fmt.Println(" No images found to process.")
return
}
// Type breakdown
fmt.Printf(" Found %d images:\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 += " ⚠ read-only"
}
fmt.Println(line)
}
}
if len(summary.ReadOnlyExts) > 0 && cfg.Format == "" {
joined := strings.Join(summary.ReadOnlyExts, ", ")
fmt.Println()
fmt.Printf(" WARNING: batch contains read-only formats (%s).\n", joined)
fmt.Println(" Output files in that format may be corrupted.")
fmt.Println(" Add -format jpg or -format png to convert them.")
}
fmt.Println()
if cfg.DryRun {
fmt.Println(" [dry-run] Output paths:")
fmt.Println()
for _, f := range summary.Files {
out := buildOutputPath(f, cfg)
fmt.Printf(" %s → %s\n", f, out)
}
fmt.Println()
return
}
fmt.Printf(" Processing with %d workers...\n\n", cfg.Workers)
processImages(summary.Files, cfg)
}
// ─────────────────────────────────────────────────────────────────────────────
// Flags y validación
// ─────────────────────────────────────────────────────────────────────────────
func parseFlags() *Config {
cfg := &Config{}
flag.StringVar(&cfg.InputDir, "dir", "", "Source directory")
flag.StringVar(&cfg.OutputDir, "out", "", "Output directory")
flag.IntVar(&cfg.Width, "width", 0, "Target width")
flag.IntVar(&cfg.Height, "height", 0, "Target height")
flag.IntVar(&cfg.Quality, "quality", 85, "JPEG quality (1-100)")
flag.StringVar(&cfg.Format, "format", "", "Output format: jpg, png, tif")
flag.StringVar(&cfg.Filter, "resample", "lanczos", "Resampling filter: lanczos, cubic, linear, box")
flag.IntVar(&cfg.Workers, "workers", 4, "Concurrent workers")
flag.BoolVar(&cfg.Verbose, "verbose", false, "Verbose output")
flag.BoolVar(&cfg.KeepStructure, "keepstructure", false, "Preserve subdirectory structure")
flag.StringVar(&cfg.Prefix, "prefix", "", "Prefix for output filename")
flag.StringVar(&cfg.Suffix, "suffix", "", "Suffix for output filename (before extension)")
flag.BoolVar(&cfg.DryRun, "dry-run", false, "Show what would be processed without executing")
flag.StringVar(&cfg.Replace, "replace", "", "Replace tokens in filename: orig/dest[,orig2/dest2] (case sensitive)")
flag.StringVar(&cfg.ReplaceI, "replacei", "", "Replace tokens in filename: orig/dest[,...] (case insensitive)")
flag.StringVar(&cfg.ReplaceP, "replacep", "", "Replace tokens in filename: orig/dest[,...] (case insensitive + preserve case)")
flag.StringVar(&cfg.FileFilter, "filter", "", "Glob pattern to filter filenames, e.g: photo_*.jpg")
flag.BoolVar(&cfg.Lowercase, "lowercase", false, "Normalize output filename to lowercase")
flag.BoolVar(&cfg.Uppercase, "uppercase", false, "Normalize output filename to uppercase")
flag.StringVar(&cfg.B64Ext, "b64", "", "Encode output as base64; optional value sets the file extension (default: b64)")
flag.StringVar(&cfg.TSFile, "ts", "", "Write a TypeScript file with all images as base64 data URIs")
flag.StringVar(&cfg.NoSpaces, "nospaces", "", "Replace spaces in output filename; optional value sets replacement char (default: _)")
configurarAyuda()
flag.Parse()
// -b64 without a value: detect if the flag was provided but left empty
// and default the extension to "b64"
flag.Visit(func(f *flag.Flag) {
if f.Name == "b64" {
cfg.B64Enabled = true
if cfg.B64Ext == "" {
cfg.B64Ext = "b64"
}
}
if f.Name == "nospaces" {
cfg.NoSpacesOn = true
if cfg.NoSpaces == "" {
cfg.NoSpaces = "_"
}
}
})
return cfg
}
func validateConfig(cfg *Config) error {
if cfg.InputDir == "" || cfg.OutputDir == "" {
flag.Usage()
return fmt.Errorf("missing required flags: -dir and -out are required")
}
if cfg.Width == 0 && cfg.Height == 0 {
return fmt.Errorf("at least one of -width or -height must be specified")
}
if cfg.Width < 0 || cfg.Height < 0 {
return fmt.Errorf("dimensions cannot be negative")
}
if cfg.Quality < 1 || cfg.Quality > 100 {
return fmt.Errorf("quality must be between 1 and 100")
}
if cfg.Format != "" &&
cfg.Format != "jpg" && cfg.Format != "jpeg" &&
cfg.Format != "png" &&
cfg.Format != "tif" && cfg.Format != "tiff" {
return fmt.Errorf("unsupported output format: %s (options: jpg, png, tif)", cfg.Format)
}
absInput, err := filepath.Abs(cfg.InputDir)
if err != nil {
return fmt.Errorf("error resolving source path: %v", err)
}
absOutput, err := filepath.Abs(cfg.OutputDir)
if err != nil {
return fmt.Errorf("error resolving output path: %v", err)
}
if absInput == absOutput && cfg.Prefix == "" && cfg.Suffix == "" && cfg.TSFile == "" && !cfg.B64Enabled {
return fmt.Errorf(
"input and output directories cannot be the same\n" +
" hint: use -prefix, -suffix, -b64 or -ts to process in place without overwriting originals")
}
if _, err := os.Stat(cfg.InputDir); os.IsNotExist(err) {
return fmt.Errorf("source directory does not exist: %s", cfg.InputDir)
}
if cfg.Workers < 1 {
cfg.Workers = 1
}
// Validate replacement pairs
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("invalid replacement pair %q — expected format: old/new", pair)
}
}
}
// Validate filter glob
if cfg.FileFilter != "" {
if _, err := filepath.Match(cfg.FileFilter, ""); err != nil {
return fmt.Errorf("invalid -filter pattern %q: %v", cfg.FileFilter, err)
}
}
if cfg.Lowercase && cfg.Uppercase {
return fmt.Errorf("-lowercase and -uppercase are mutually exclusive")
}
if cfg.B64Enabled && cfg.TSFile != "" {
return fmt.Errorf("-b64 and -ts are mutually exclusive")
}
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)
// Replace spaces
if cfg.NoSpacesOn {
stem = strings.ReplaceAll(stem, " ", cfg.NoSpaces)
}
// Case normalisation — applied after all other stem transformations,
// never on the extension (avoids .JPG or .TIF on case-sensitive filesystems)
switch {
case cfg.Lowercase:
stem = strings.ToLower(stem)
case cfg.Uppercase:
stem = strings.ToUpper(stem)
}
// Base64 extension override — applied last, replaces the image extension
if cfg.B64Enabled {
outExt = "." + cfg.B64Ext
}
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()
tsEntries []ProcessResult // collected in order for -ts
)
jobs := make(chan string, len(files))
resultsCh := make(chan ProcessResult, 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)
resultsCh <- 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] ✓ %-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)
// Collect results while workers run
go func() {
wg.Wait()
close(resultsCh)
}()
for r := range resultsCh {
if r.Success && cfg.TSFile != "" {
tsEntries = append(tsEntries, r)
}
}
// Write TypeScript file
if cfg.TSFile != "" && len(tsEntries) > 0 {
if err := writeTSFile(cfg.TSFile, tsEntries); err != nil {
fmt.Printf("\n ✗ error writing TypeScript file: %v\n", err)
} else {
fmt.Printf("\n ✓ TypeScript file written: %s (%d images)\n", cfg.TSFile, len(tsEntries))
}
}
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(" Done in %s\n\n", elapsed)
fmt.Printf(" Images %d total / %d ok / %d errors\n",
total, ok, errCount.Load())
fmt.Printf(" Size %.2f MB in → %.2f MB out (%s saved)\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),
}
if fi, err := os.Stat(inputPath); err == nil {
result.InputSize = fi.Size()
}
outputPath := buildOutputPath(inputPath, cfg)
result.OutputPath = outputPath
// Stem for TS key (after all naming transformations, without extension)
base := filepath.Base(outputPath)
result.ImageKey = base[:len(base)-len(filepath.Ext(base))]
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
result.Error = fmt.Errorf("error creating directory: %v", err)
return result
}
f, err := os.Open(inputPath)
if err != nil {
result.Error = fmt.Errorf("error opening file: %v", err)
return result
}
defer f.Close()
img, srcFormat, err := image.Decode(f)
if err != nil {
result.Error = fmt.Errorf("error decoding image: %v", err)
return result
}
if cfg.Verbose {
bounds := img.Bounds()
log.Printf(" %s: format=%s size=%dx%d",
result.Filename, srcFormat, bounds.Dx(), bounds.Dy())
}
resized := imaging.Resize(img, cfg.Width, cfg.Height, getResampleFilter(cfg.Filter))
// Encode image into an in-memory buffer first
var buf bytes.Buffer
outputFormat := cfg.Format
if outputFormat == "" {
// When b64 or ts mode, we need a real image format — default to the source format
srcExt := strings.ToLower(filepath.Ext(inputPath))
srcMime := extToFormat(strings.TrimPrefix(srcExt, "."))
if cfg.B64Enabled || cfg.TSFile != "" {
outputFormat = srcMime
} else {
ext := strings.ToLower(filepath.Ext(outputPath))
outputFormat = strings.TrimPrefix(ext, ".")
}
}
mimeType, encErr := encodeImage(&buf, resized, outputFormat, cfg.Quality)
if encErr != nil {
result.Error = fmt.Errorf("error encoding image: %v", encErr)
return result
}
// ── Base64 mode ──────────────────────────────────────────────────────────
if cfg.B64Enabled || cfg.TSFile != "" {
encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
result.B64Data = "data:" + mimeType + ";base64," + encoded
}
// ── Write output file ────────────────────────────────────────────────────
if cfg.TSFile == "" {
// Write b64 text file or normal binary image
var writeErr error
if cfg.B64Enabled {
writeErr = os.WriteFile(outputPath, []byte(result.B64Data), 0644)
} else {
writeErr = os.WriteFile(outputPath, buf.Bytes(), 0644)
}
if writeErr != nil {
result.Error = fmt.Errorf("error writing output file: %v", writeErr)
return result
}
}
// When -ts is active, writing is deferred to processImages after all goroutines finish
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
}
}
// encodeImage writes the image into buf using the given format and returns
// the MIME type string to use in data URIs.
func encodeImage(buf *bytes.Buffer, img image.Image, format string, quality int) (mimeType string, err error) {
switch strings.ToLower(format) {
case "jpg", "jpeg":
err = jpeg.Encode(buf, img, &jpeg.Options{Quality: quality})
mimeType = "image/jpeg"
case "png":
err = png.Encode(buf, img)
mimeType = "image/png"
case "tif", "tiff":
err = tiff.Encode(buf, img, &tiff.Options{Compression: tiff.Deflate, Predictor: true})
mimeType = "image/tiff"
case "webp", "bmp":
err = fmt.Errorf("format %s is read-only — use -format jpg, png or tif", format)
default:
err = fmt.Errorf("unknown output format: %s", format)
}
return
}
// extToFormat normalises a file extension to a canonical format name.
func extToFormat(ext string) string {
switch ext {
case "jpeg":
return "jpg"
case "tiff":
return "tif"
default:
return ext
}
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return "…" + s[len(s)-(max-1):]
}
// ─────────────────────────────────────────────────────────────────────────────
// TypeScript export file
// ─────────────────────────────────────────────────────────────────────────────
func writeTSFile(path string, entries []ProcessResult) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
var sb strings.Builder
sb.WriteString("export const images: Record<string, string> = {\n")
for _, e := range entries {
// Sanitize key: replace any non-alphanumeric/underscore chars with _
key := sanitizeTSKey(e.ImageKey)
fmt.Fprintf(&sb, " %s: %q,\n", key, e.B64Data)
}
sb.WriteString("}\n")
return os.WriteFile(path, []byte(sb.String()), 0644)
}
// sanitizeTSKey ensures the key is a valid JS identifier.
// Replaces sequences of non-word characters with underscores,
// and prepends _ if the key starts with a digit.
func sanitizeTSKey(s string) string {
re := regexp.MustCompile(`[^\w]`)
key := re.ReplaceAllString(s, "_")
if len(key) > 0 && key[0] >= '0' && key[0] <= '9' {
key = "_" + key
}
return key
}
// 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.Print(`
██╗███╗ ███╗ █████╗ ██████╗ ██╗███╗ ██╗ █████╗ ████████╗ ██████╗ ██████╗
██║████╗ ████║██╔══██╗██╔════╝ ██║████╗ ██║██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗
██║██╔████╔██║███████║██║ ███╗██║██╔██╗ ██║███████║ ██║ ██║ ██║██████╔╝
██║██║╚██╔╝██║██╔══██║██║ ██║██║██║╚██╗██║██╔══██║ ██║ ██║ ██║██╔══██╗
██║██║ ╚═╝ ██║██║ ██║╚██████╔╝██║██║ ╚████║██║ ██║ ██║ ╚██████╔╝██║ ██║
╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
IMAGINATOR VER 2.7
──────────────────────────────────────────────────────────────────────────────
activething.com/imaginator · dev@activething.com · ©2026 ActiveThing
──────────────────────────────────────────────────────────────────────────────
USAGE
imaginator -dir <source> -out <dest> { -width N | -height N } [options]
REQUIRED
-dir string Source directory
-out string Output directory
At least one of:
-width int Target width (keeps aspect ratio if -height is omitted)
-height int Target height (keeps aspect ratio if -width is omitted)
SUPPORTED FORMATS
Read jpg · jpeg · png · tif · tiff · bmp · webp
Write jpg · png · tif
(bmp and webp are read-only — use -format jpg, png or tif to convert)
IMAGE OPTIONS
-quality int JPEG quality 1–100 [85]
-format string Output format: jpg, png, tif [keep original]
-resample string Resampling filter: lanczos · cubic · linear · box [lanczos]
-b64 string Encode output as base64; optional value = file extension [b64]
-b64 → saves as nombre.b64
-b64 png → saves as nombre.png (base64 content)
-ts string Write a TypeScript file with all images as base64 data URIs
-ts assets.ts → export const images: Record<string,string> = { ... }
(-b64 and -ts are mutually exclusive)
NAMING OPTIONS
-prefix string Prefix for output filename [""]
-suffix string Suffix for output filename (before extension) [""]
-lowercase Normalize output filename to lowercase [false]
-uppercase Normalize output filename to uppercase [false]
-nospaces string Replace spaces in filename; optional value = replacement char [_]
-nospaces → "foto de viaje.jpg" → "foto_de_viaje.jpg"
-nospaces - → "foto de viaje.jpg" → "foto-de-viaje.jpg"
-nospaces "" → "foto de viaje.jpg" → "fotodeviaje.jpg"
-replace string Replace token in filename: old/new[,old2/new2]
case sensitive: photo→picture, Photo→Photo
-replacei string Same as -replace but case insensitive
photo→picture, Photo→picture, PHOTO→picture
-replacep string Same as -replacei but preserves capitalization
photo→picture, Photo→Picture, PHOTO→PICTURE
FILTER OPTIONS
-filter string Glob pattern on filename to select input files [""]
e.g: "photo_*.jpg" "DSC_????.png" "*.webp"
EXECUTION OPTIONS
-workers int Concurrent workers [4]
-keepstructure Preserve subdirectory structure [false]
-dry-run Show what would be processed without running [false]
-verbose Verbose output per image [false]
EXAMPLES
# Resize to 800 px wide, preserve aspect ratio
imaginator -dir ./photos -out ./thumbs -width 800
# Convert everything to JPG 1920×1080 at quality 90
imaginator -dir ./raw -out ./web -width 1920 -height 1080 -format jpg -quality 90
# 8 workers, preserve folder tree, quality 85
imaginator -dir ./raw -out ./web -width 1024 -workers 8 -keepstructure -quality 85
# Process in place adding suffix "_800w"
imaginator -dir ./photos -out ./photos -width 800 -suffix _800w
# Process only files matching a glob
imaginator -dir ./raw -out ./web -width 1920 -filter "DSC_*.jpg"
# Replace tokens in output filenames (case sensitive)
imaginator -dir ./raw -out ./web -width 800 -replace "photo/img,picture/pic"
# Replace preserving capitalization
imaginator -dir ./raw -out ./web -width 800 -replacep "photo/image"
# photo_001.jpg → image_001.jpg
# Photo_001.jpg → Image_001.jpg
# PHOTO_001.jpg → IMAGE_001.jpg
# Encode all output images as base64 files (.b64)
imaginator -dir ./raw -out ./web -width 800 -b64
# Encode as base64 but keep .png extension
imaginator -dir ./raw -out ./web -width 800 -format png -b64 png
# Generate a TypeScript file with all images as data URIs
imaginator -dir ./icons -out ./dist -width 64 -format png -ts src/assets/icons.ts
# Replace spaces in output filenames with underscores
imaginator -dir ./raw -out ./web -width 800 -nospaces
# Replace spaces with hyphens
imaginator -dir ./raw -out ./web -width 800 -nospaces -
# Normalize all output filenames to lowercase
imaginator -dir ./raw -out ./web -width 800 -lowercase
# Normalize mixed batch (jpg+png+webp) all to jpg
imaginator -dir ./raw -out ./web -width 1920 -format jpg
─────────────────────────────────────────────────────────────────────────────
"I do not wish to walk on water." — Hermann Hesse, Siddhartha
─────────────────────────────────────────────────────────────────────────────
`, version)
}
}

Powered by TurnKey Linux.