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

8 months ago
/*
7 months ago
——————————————————————————————————————————————————————————————————————————
Proyecto Imaginator
8 months ago
7 months ago
—————————————————————————————————————————————————————————————————————————
8 months ago
7 months ago
Fichero main.go
Package Imaginator
Autor Juan V. Navarro juanvnl@activething.com
Creado 26/01/2026
Mejorado 20/03/2026
8 months ago
7 months ago
—————————————————————————————————————————————————————————————————————————
8 months ago
7 months ago
LICENSES AND TERMS OF USE
-------------------------
8 months ago
7 months ago
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
8 months ago
7 months ago
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.
8 months ago
7 months ago
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.
8 months ago
7 months ago
—————————————————————————————————————————————————————————————————————————
8 months ago
7 months ago
Web : activething.com | activething.com/Imaginator
git : g.activething.com | github.com/activething/Imaginator
Correo : dev@activething.com
8 months ago
7 months ago
—————————————————————————————————————————————————————————————————————————
8 months ago
7 months ago
"No deseo caminar sobre el agua", dijo Siddhartha.
Que los antiguos chamanes se contenten con tales habilidades.
—— Hermann Hesse, Siddhartha
8 months ago
7 months ago
—————————————————————————————————————————————————————————————————————————
8 months ago
7 months ago
Copyright (c) 2026 Active Thing
8 months ago
7 months ago
—————————————————————————————————————————————————————————————————————————
8 months ago
*/
package main
import (
7 months ago
"bytes"
"encoding/base64"
8 months ago
"flag"
"fmt"
"image"
"image/jpeg"
"image/png"
"log"
"os"
"path/filepath"
7 months ago
"regexp"
8 months ago
"strings"
"sync"
"sync/atomic"
7 months ago
"time"
"unicode"
8 months ago
"github.com/disintegration/imaging"
7 months ago
_ "golang.org/x/image/bmp"
"golang.org/x/image/tiff"
8 months ago
_ "golang.org/x/image/webp"
)
7 months ago
const version = "2.6.0"
7 months ago
// ─────────────────────────────────────────────────────────────────────────────
// Tipos
// ─────────────────────────────────────────────────────────────────────────────
8 months ago
type Config struct {
InputDir string
OutputDir string
Width int
Height int
Quality int
Format string
Filter string
Workers int
Verbose bool
KeepStructure bool
7 months ago
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
7 months ago
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
7 months ago
}
// ReplacePair representa un par de sustitución ya parseado
type ReplacePair struct {
From string
To string
8 months ago
}
type ProcessResult struct {
7 months ago
Filename string
OutputPath string
Success bool
Error error
Duration time.Duration
InputSize int64
OutputSize int64
7 months ago
B64Data string // populated when B64Enabled or TSFile is set: "data:image/png;base64,..."
ImageKey string // stem used as key in the TS file
7 months ago
}
type ImageSummary struct {
Files []string
ByExt map[string]int
7 months ago
ReadOnlyExts []string // read-only formats present in the batch
8 months ago
}
7 months ago
// ─────────────────────────────────────────────────────────────────────────────
// Entrada
// ─────────────────────────────────────────────────────────────────────────────
8 months ago
func main() {
cfg := parseFlags()
if err := validateConfig(cfg); err != nil {
log.Fatal(err)
}
if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil {
7 months ago
log.Fatalf("error creating output directory: %v", err)
8 months ago
}
7 months ago
summary, err := collectImageFiles(cfg.InputDir, cfg.FileFilter)
8 months ago
if err != nil {
7 months ago
log.Fatalf("error reading source directory: %v", err)
8 months ago
}
7 months ago
if len(summary.Files) == 0 {
7 months ago
fmt.Println(" No images found to process.")
8 months ago
return
}
7 months ago
// Type breakdown
fmt.Printf(" Found %d images:\n", len(summary.Files))
7 months ago
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 == "" {
7 months ago
line += " ⚠ read-only"
7 months ago
}
fmt.Println(line)
}
}
if len(summary.ReadOnlyExts) > 0 && cfg.Format == "" {
joined := strings.Join(summary.ReadOnlyExts, ", ")
fmt.Println()
7 months ago
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.")
7 months ago
}
fmt.Println()
if cfg.DryRun {
7 months ago
fmt.Println(" [dry-run] Output paths:")
7 months ago
fmt.Println()
for _, f := range summary.Files {
out := buildOutputPath(f, cfg)
fmt.Printf(" %s → %s\n", f, out)
}
fmt.Println()
return
}
8 months ago
7 months ago
fmt.Printf(" Processing with %d workers...\n\n", cfg.Workers)
7 months ago
processImages(summary.Files, cfg)
8 months ago
}
7 months ago
// ─────────────────────────────────────────────────────────────────────────────
// Flags y validación
// ─────────────────────────────────────────────────────────────────────────────
8 months ago
func parseFlags() *Config {
cfg := &Config{}
7 months ago
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: _)")
8 months ago
configurarAyuda()
flag.Parse()
7 months ago
// -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 = "_"
}
}
})
8 months ago
return cfg
}
func validateConfig(cfg *Config) error {
if cfg.InputDir == "" || cfg.OutputDir == "" {
flag.Usage()
7 months ago
return fmt.Errorf("missing required flags: -dir and -out are required")
8 months ago
}
if cfg.Width == 0 && cfg.Height == 0 {
7 months ago
return fmt.Errorf("at least one of -width or -height must be specified")
8 months ago
}
if cfg.Width < 0 || cfg.Height < 0 {
7 months ago
return fmt.Errorf("dimensions cannot be negative")
8 months ago
}
if cfg.Quality < 1 || cfg.Quality > 100 {
7 months ago
return fmt.Errorf("quality must be between 1 and 100")
8 months ago
}
7 months ago
if cfg.Format != "" &&
cfg.Format != "jpg" && cfg.Format != "jpeg" &&
cfg.Format != "png" &&
cfg.Format != "tif" && cfg.Format != "tiff" {
7 months ago
return fmt.Errorf("unsupported output format: %s (options: jpg, png, tif)", cfg.Format)
8 months ago
}
absInput, err := filepath.Abs(cfg.InputDir)
if err != nil {
7 months ago
return fmt.Errorf("error resolving source path: %v", err)
8 months ago
}
absOutput, err := filepath.Abs(cfg.OutputDir)
if err != nil {
7 months ago
return fmt.Errorf("error resolving output path: %v", err)
8 months ago
}
7 months ago
if absInput == absOutput && cfg.Prefix == "" && cfg.Suffix == "" && cfg.TSFile == "" && !cfg.B64Enabled {
7 months ago
return fmt.Errorf(
7 months ago
"input and output directories cannot be the same\n" +
" hint: use -prefix, -suffix, -b64 or -ts to process in place without overwriting originals")
8 months ago
}
if _, err := os.Stat(cfg.InputDir); os.IsNotExist(err) {
7 months ago
return fmt.Errorf("source directory does not exist: %s", cfg.InputDir)
8 months ago
}
if cfg.Workers < 1 {
cfg.Workers = 1
}
7 months ago
// Validate replacement pairs
7 months ago
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] == "" {
7 months ago
return fmt.Errorf("invalid replacement pair %q — expected format: old/new", pair)
7 months ago
}
}
}
7 months ago
// Validate filter glob
7 months ago
if cfg.FileFilter != "" {
if _, err := filepath.Match(cfg.FileFilter, ""); err != nil {
7 months ago
return fmt.Errorf("invalid -filter pattern %q: %v", cfg.FileFilter, err)
7 months ago
}
}
if cfg.Lowercase && cfg.Uppercase {
7 months ago
return fmt.Errorf("-lowercase and -uppercase are mutually exclusive")
}
if cfg.B64Enabled && cfg.TSFile != "" {
return fmt.Errorf("-b64 and -ts are mutually exclusive")
7 months ago
}
8 months ago
return nil
}
7 months ago
// ─────────────────────────────────────────────────────────────────────────────
// Recolección de ficheros
// ─────────────────────────────────────────────────────────────────────────────
func collectImageFiles(root string, glob string) (*ImageSummary, error) {
8 months ago
validExts := map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".webp": true,
7 months ago
".tif": true,
".tiff": true,
".bmp": true,
}
readOnlyExts := map[string]bool{
".webp": true,
".bmp": true,
8 months ago
}
7 months ago
summary := &ImageSummary{
ByExt: make(map[string]int),
}
seen := map[string]bool{}
8 months ago
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))
7 months ago
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
8 months ago
}
return nil
})
7 months ago
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)
7 months ago
// 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)
7 months ago
switch {
case cfg.Lowercase:
stem = strings.ToLower(stem)
case cfg.Uppercase:
stem = strings.ToUpper(stem)
}
7 months ago
// Base64 extension override — applied last, replaces the image extension
if cfg.B64Enabled {
outExt = "." + cfg.B64Ext
}
7 months ago
newName := cfg.Prefix + stem + cfg.Suffix + outExt
if cfg.KeepStructure {
return filepath.Join(cfg.OutputDir, dir, newName)
}
return filepath.Join(cfg.OutputDir, newName)
8 months ago
}
7 months ago
// ─────────────────────────────────────────────────────────────────────────────
// Orquestación de workers
// ─────────────────────────────────────────────────────────────────────────────
8 months ago
func processImages(files []string, cfg *Config) {
var (
wg sync.WaitGroup
7 months ago
mu sync.Mutex
8 months ago
processed atomic.Int32
7 months ago
errCount atomic.Int32
totalIn atomic.Int64
totalOut atomic.Int64
8 months ago
total = int32(len(files))
7 months ago
start = time.Now()
7 months ago
tsEntries []ProcessResult // collected in order for -ts
8 months ago
)
jobs := make(chan string, len(files))
7 months ago
resultsCh := make(chan ProcessResult, len(files))
8 months ago
for i := 0; i < cfg.Workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for inputPath := range jobs {
result := processImage(inputPath, cfg)
7 months ago
resultsCh <- result
8 months ago
current := processed.Add(1)
7 months ago
mu.Lock()
8 months ago
if result.Success {
7 months ago
totalIn.Add(result.InputSize)
totalOut.Add(result.OutputSize)
8 months ago
if cfg.Verbose {
7 months ago
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)
8 months ago
}
} else {
7 months ago
errCount.Add(1)
fmt.Printf(" [%d/%d] ✗ %s: %v\n", current, total, result.Filename, result.Error)
8 months ago
}
7 months ago
mu.Unlock()
8 months ago
}
}()
}
7 months ago
for _, f := range files {
jobs <- f
8 months ago
}
close(jobs)
7 months ago
// 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))
}
}
7 months ago
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")
7 months ago
fmt.Printf(" Done in %s\n\n", elapsed)
fmt.Printf(" Images %d total / %d ok / %d errors\n",
7 months ago
total, ok, errCount.Load())
7 months ago
fmt.Printf(" Size %.2f MB in → %.2f MB out (%s saved)\n",
7 months ago
inMB, outMB, saving)
fmt.Printf(" ══════════════════════════════════════════════════════\n\n")
8 months ago
}
7 months ago
// ─────────────────────────────────────────────────────────────────────────────
// Procesado individual
// ─────────────────────────────────────────────────────────────────────────────
8 months ago
func processImage(inputPath string, cfg *Config) ProcessResult {
7 months ago
start := time.Now()
8 months ago
result := ProcessResult{
Filename: filepath.Base(inputPath),
}
7 months ago
if fi, err := os.Stat(inputPath); err == nil {
result.InputSize = fi.Size()
8 months ago
}
7 months ago
outputPath := buildOutputPath(inputPath, cfg)
result.OutputPath = outputPath
8 months ago
7 months ago
// Stem for TS key (after all naming transformations, without extension)
base := filepath.Base(outputPath)
result.ImageKey = base[:len(base)-len(filepath.Ext(base))]
8 months ago
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
7 months ago
result.Error = fmt.Errorf("error creating directory: %v", err)
8 months ago
return result
}
f, err := os.Open(inputPath)
if err != nil {
7 months ago
result.Error = fmt.Errorf("error opening file: %v", err)
8 months ago
return result
}
defer f.Close()
7 months ago
img, srcFormat, err := image.Decode(f)
8 months ago
if err != nil {
7 months ago
result.Error = fmt.Errorf("error decoding image: %v", err)
8 months ago
return result
}
if cfg.Verbose {
bounds := img.Bounds()
7 months ago
log.Printf(" %s: format=%s size=%dx%d",
7 months ago
result.Filename, srcFormat, bounds.Dx(), bounds.Dy())
8 months ago
}
7 months ago
resized := imaging.Resize(img, cfg.Width, cfg.Height, getResampleFilter(cfg.Filter))
8 months ago
7 months ago
// Encode image into an in-memory buffer first
var buf bytes.Buffer
8 months ago
outputFormat := cfg.Format
if outputFormat == "" {
7 months ago
// 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, ".")
}
8 months ago
}
7 months ago
mimeType, encErr := encodeImage(&buf, resized, outputFormat, cfg.Quality)
if encErr != nil {
result.Error = fmt.Errorf("error encoding image: %v", encErr)
return result
8 months ago
}
7 months ago
// ── Base64 mode ──────────────────────────────────────────────────────────
if cfg.B64Enabled || cfg.TSFile != "" {
encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
result.B64Data = "data:" + mimeType + ";base64," + encoded
8 months ago
}
7 months ago
// ── 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
7 months ago
if fi, err := os.Stat(outputPath); err == nil {
result.OutputSize = fi.Size()
}
8 months ago
result.Success = true
7 months ago
result.Duration = time.Since(start)
8 months ago
return result
}
7 months ago
// ─────────────────────────────────────────────────────────────────────────────
// Utilidades
// ─────────────────────────────────────────────────────────────────────────────
8 months ago
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
}
}
7 months ago
// 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
}
}
7 months ago
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return "…" + s[len(s)-(max-1):]
}
// ─────────────────────────────────────────────────────────────────────────────
7 months ago
// TypeScript export file
7 months ago
// ─────────────────────────────────────────────────────────────────────────────
7 months ago
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
}
7 months ago
// 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)
}
}
8 months ago
func configurarAyuda() {
flag.Usage = func() {
7 months ago
fmt.Print(`
7 months ago
██╗███╗ ███╗ █████╗ ██████╗ ██╗███╗ ██╗ █████╗ ████████╗ ██████╗ ██████╗
██║████╗ ████║██╔══██╗██╔════╝ ██║████╗ ██║██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗
██║██╔████╔██║███████║██║ ███╗██║██╔██╗ ██║███████║ ██║ ██║ ██║██████╔╝
██║██║╚██╔╝██║██╔══██║██║ ██║██║██║╚██╗██║██╔══██║ ██║ ██║ ██║██╔══██╗
██║██║ ╚═╝ ██║██║ ██║╚██████╔╝██║██║ ╚████║██║ ██║ ██║ ╚██████╔╝██║ ██║
╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
7 months ago
IMAGINATOR VER 2.7
7 months ago
──────────────────────────────────────────────────────────────────────────────
7 months ago
activething.com/imaginator · dev@activething.com · ©2026 ActiveThing
7 months ago
──────────────────────────────────────────────────────────────────────────────
7 months ago
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
7 months ago
imaginator -dir ./raw -out ./web -width 1920 -filter "DSC_*.jpg"
7 months ago
# 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
7 months ago
imaginator -dir ./raw -out ./web -width 800 -lowercase
7 months ago
# Normalize mixed batch (jpg+png+webp) all to jpg
imaginator -dir ./raw -out ./web -width 1920 -format jpg
7 months ago
─────────────────────────────────────────────────────────────────────────────
7 months ago
"I do not wish to walk on water." — Hermann Hesse, Siddhartha
7 months ago
─────────────────────────────────────────────────────────────────────────────
7 months ago
7 months ago
`, version)
8 months ago
}
}

Powered by TurnKey Linux.