diff --git a/AvifGenerator.csproj b/AvifGenerator.csproj index d398f26..19888f6 100644 --- a/AvifGenerator.csproj +++ b/AvifGenerator.csproj @@ -10,6 +10,9 @@ + + + diff --git a/Examples/example.csv b/Examples/example.csv index 4196beb..14e9b91 100644 --- a/Examples/example.csv +++ b/Examples/example.csv @@ -11,4 +11,4 @@ office@iga.com.pl,iga.com.pl,+48 / 48 663 54 51,IGA-Cookies at every moment,iga. office@iga.com.pl,iga.com.pl,+48 / 48 663 54 51,IGA-Cookies at every moment,iga.com.pl,Markizy czekoladowe,klasyczne ciastka kruche z kremem kakaowym office@iga.com.pl,iga.com.pl,+48 / 48 663 54 51,IGA-Cookies at every moment,iga.com.pl,Markizy czekoladowe,dlaczego są tak popularne wśród konsumentów office@iga.com.pl,iga.com.pl,+48 / 48 663 54 51,IGA-Cookies at every moment,iga.com.pl,Wyroby cukiernicze producent w Polsce,standardy jakości i certyfikaty -office@iga.com.pl,iga.com.pl,+48 / 48 663 54 51,IGA-Cookies at every moment,iga.com.pl,Ciastka kruche producent,jak powstają klasyczne wypieki przemysłowe \ No newline at end of file +office@iga.com.pl,iga.com.pl,+48 / 48 663 54 51,IGA-Cookies at every moment,iga.com.pl,Ciastka kruche producent,jak powstają klasyczne wypieki przemysłowe diff --git a/GUI/gui.py b/GUI/gui.py deleted file mode 100644 index 720fd19..0000000 --- a/GUI/gui.py +++ /dev/null @@ -1,31 +0,0 @@ -import tkinter as tk -from tkinter import ttk -import logic -import settings - - -def run_gui(): - root = tk.Tk() - root.title("AvifGenerator") - root.geometry("500x450") - root.resizable(False, False) - - main_frame = ttk.Frame(root, padding=20) - main_frame.pack(fill="both", expand=True) - - # Кнопки - buttons_info = [ - ("Open CSV file", logic.choose_csv), - ("Open Template", logic.choose_template), - ("Choose exporting directory", logic.choose_exporting), - ("PREVIEW", logic.generate_preview), - ("RUN", logic.run_subprocess), - ("Settings ⚙️", lambda: settings.open_settings(root)), # передаємо root - ] - - # Створення кнопок - for text, cmd in buttons_info: - btn = ttk.Button(main_frame, text=text, command=cmd) - btn.pack(fill="x", pady=10) - - root.mainloop() diff --git a/GUI/logic.py b/GUI/logic.py deleted file mode 100644 index 9b30692..0000000 --- a/GUI/logic.py +++ /dev/null @@ -1,142 +0,0 @@ -import subprocess -from tkinter import filedialog, messagebox -import os -import sys -import csv -import math -import glob -from PIL import Image, ImageTk -import tkinter as tk - -path_csv = "" -path_template = "" -path_exporting = "" - - -def get_base_dir(): - """Визначає реальну папку, навіть якщо це скомпільований .exe""" - if getattr(sys, 'frozen', False): - return os.path.dirname(sys.executable) - return os.path.dirname(os.path.abspath(__file__)) - - -def choose_csv(): - global path_csv - path_csv = filedialog.askopenfilename( - title="Choose CSV file", filetypes=[("CSV files", "*.csv")]) - - -def choose_template(): - global path_template - path_template = filedialog.askopenfilename( - title="Choose Template", filetypes=[("PSD/PNG files", "*.*")]) - - -def choose_exporting(): - global path_exporting - path_exporting = filedialog.askdirectory(title="Choose exporting folder") - - -def generate_preview(): - - base_dir = get_base_dir() - exe_name = "AvifGenerator.exe" if sys.platform == "win32" else "AvifGenerator" - exe_path = os.path.join(base_dir, exe_name) - path_config = os.path.join(base_dir, "config.json") - - try: - with open(path_csv, "r", encoding="utf-8") as f: - reader = csv.reader(f) - header = next(reader) - try: - first_row = next(reader) - except StopIteration: - messagebox.showerror("Error", "CSV file is empty") - return - preview_csv = os.path.join(base_dir, "preview_temp.csv") - with open(preview_csv, "w", encoding="utf-8", newline='') as f: - writer = csv.writer(f) - writer.writerow(header) - writer.writerow(first_row) - - kwargs = { - "creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {} - result = subprocess.run( - [exe_path, preview_csv, path_config, path_template, path_exporting], - capture_output=True, text=True, **kwargs - ) - - if os.path.exists(preview_csv): - os.remove(preview_csv) - - if result.returncode != 0: - messagebox.showerror( - "Preview Error", result.stdout or result.stderr) - return - - show_preview_image() - except Exception as e: - messagebox.showerror("Error", str(e)) - - -def show_preview_image(): - list_of_files = glob.glob(os.path.join(path_exporting, '*')) - if not list_of_files: - messagebox.showinfo( - "Error", "No generated file found in export folder.") - return - - latest_file = max(list_of_files, key=os.path.getctime) - - preview_win = tk.Toplevel() - preview_win.title("Preview Image") - - try: - img = Image.open(latest_file) - - img.thumbnail((800, 800)) - - photo = ImageTk.PhotoImage(img) - - lbl = tk.Label(preview_win, image=photo) - lbl.image = photo - lbl.pack(padx=10, pady=10) - - except Exception as e: - messagebox.showerror("Image Error", f"Cannot open image: {e}") - - -def run_subprocess(): - if not path_csv or not path_template or not path_exporting: - messagebox.showwarning( - "Please", "Please, choose all files and a folder for the export!") - return - - base_dir = get_base_dir() - exe_name = "AvifGenerator.exe" if sys.platform == "win32" else "AvifGenerator" - exe_path = os.path.join(base_dir, exe_name) - # Конфіг завжди шукаємо поруч - path_config = os.path.join(base_dir, "config.json") - - if not os.path.exists(exe_path): - messagebox.showerror("Error", f"Generator not found:\n{exe_path}") - return - - kwargs = { - "creationflags": subprocess.CREATE_NO_WINDOW} if sys.platform == "win32" else {} - - try: - result = subprocess.run( - [exe_path, path_csv, path_config, path_template, path_exporting], - capture_output=True, text=True, **kwargs - ) - - if result.returncode == 0: - messagebox.showinfo("Success!", "Successfully generated!") - elif result.returncode == 2: - messagebox.showwarning("Partial", "Generated error.\nLook. report.txt") - else: - messagebox.showerror("Error", result.stdout or result.stderr) - - except Exception as e: - messagebox.showerror("Critical Error", str(e)) diff --git a/GUI/main.py b/GUI/main.py deleted file mode 100644 index b359114..0000000 --- a/GUI/main.py +++ /dev/null @@ -1,4 +0,0 @@ -import gui - -if __name__ == "__main__": - gui.run_gui() diff --git a/GUI/requirements.txt b/GUI/requirements.txt deleted file mode 100644 index aa7ccae..0000000 --- a/GUI/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Pillow -pillow-heif diff --git a/GUI/settings.py b/GUI/settings.py deleted file mode 100644 index 9dbd59f..0000000 --- a/GUI/settings.py +++ /dev/null @@ -1,139 +0,0 @@ -import tkinter as tk -from tkinter import ttk, colorchooser, font, messagebox -import json -import os -import sys - - -def get_base_dir(): - if getattr(sys, 'frozen', False): - return os.path.dirname(sys.executable) - return os.path.dirname(os.path.abspath(__file__)) - - -CONFIG_PATH = os.path.join(get_base_dir(), "config.json") - - -def load_config(): - if os.path.exists(CONFIG_PATH): - with open(CONFIG_PATH, "r", encoding="utf-8") as f: - return json.load(f) - return {} # Якщо конфігу немає, повертаємо порожній словник - - -def save_config(config): - with open(CONFIG_PATH, "w", encoding="utf-8") as f: - json.dump(config, f, indent=4, ensure_ascii=False) - - -def open_settings(parent): - config = load_config() - - if not config: - messagebox.showinfo( - "Error", "File config.json has not been found in current directory.") - return - - win = tk.Toplevel(parent) - win.title("Settings") - win.geometry("500x400") - - notebook = ttk.Notebook(win) - notebook.pack(fill="both", expand=True) - - field_vars = {} - - # ДИНАМІКА: Йдемо по реальних ключах з config.json (EMAIL, OPIS тощо) - for field_name, field_data in config.items(): - frame = ttk.Frame(notebook, padding=10) - notebook.add(frame, text=field_name.upper()) - field_vars[field_name] = {} - - # Шрифт - tk.Label(frame, text="Font file:").grid(row=0, column=0, sticky="w") - font_var = tk.StringVar(value=field_data.get("font_file", "arial.ttf")) - font_combo = ttk.Combobox( - frame, textvariable=font_var, values=list(font.families())) - font_combo.grid(row=0, column=1, sticky="ew") - field_vars[field_name]["font_file"] = font_var - - # Колір - tk.Label(frame, text="Color:").grid(row=1, column=0, sticky="w") - color_val = field_data.get("color", [0, 0, 0]) - hex_color = "#%02x%02x%02x" % tuple(color_val) - color_var = tk.StringVar(value=hex_color) - - color_display = tk.Label( - frame, text=" ", bg=hex_color, relief="sunken") - color_display.grid(row=1, column=1, sticky="w") - - # X координата - tk.Label(frame, text="X:").grid(row=2, column=0, sticky="w") - x_var = tk.IntVar(value=field_data.get("x", 0)) - x_entry = ttk.Entry(frame, textvariable=x_var) - x_entry.grid(row=2, column=1, sticky="ew") - field_vars[field_name]["x"] = x_var - - # Y координата - tk.Label(frame, text="Y:").grid(row=3, column=0, sticky="w") - y_var = tk.IntVar(value=field_data.get("y", 0)) - y_entry = ttk.Entry(frame, textvariable=y_var) - y_entry.grid(row=3, column=1, sticky="ew") - field_vars[field_name]["y"] = y_var - - # BoxWidth - tk.Label(frame, text="Box Width:").grid(row=4, column=0, sticky="w") - box_width_var = tk.IntVar(value=field_data.get("BoxWidth", 100)) - box_width_entry = ttk.Entry(frame, textvariable=box_width_var) - box_width_entry.grid(row=4, column=1, sticky="ew") - field_vars[field_name]["box_width"] = box_width_var - - # BoxHeight - tk.Label(frame, text="Box Height:").grid(row=5, column=0, sticky="w") - box_height_var = tk.IntVar(value=field_data.get("BoxHeight", 50)) - box_height_entry = ttk.Entry(frame, textvariable=box_height_var) - box_height_entry.grid(row=5, column=1, sticky="ew") - field_vars[field_name]["box_height"] = box_height_var - - def choose_color(var=color_var, display=color_display): - color_code = colorchooser.askcolor( - title="Choose color", initialcolor=var.get()) - if color_code[1]: # Якщо користувач не натиснув Cancel - display.config(bg=color_code[1]) - var.set(color_code[1]) - - tk.Button(frame, text="Choose color", - command=choose_color).grid(row=1, column=2) - field_vars[field_name]["color"] = color_display - - def save_all(): - for f in config.keys(): - config[f]["font_file"] = field_vars[f]["font_file"].get() - - bg = field_vars[f]["color"].cget("bg") - r, g, b = win.winfo_rgb(bg) - config[f]["color"] = [r//256, g//256, b//256] - - try: - config[f]["x"] = field_vars[f]["x"].get() - except tk.TclError: - config[f]["x"] = 0 - try: - config[f]["y"] = field_vars[f]["y"].get() - except tk.TclError: - config[f]["y"] = 0 - try: - config[f]["BoxWidth"] = field_vars[f]["box_width"].get() - except tk.TclError: - config[f]["BoxWidth"] = 100 - try: - config[f]["BoxHeight"] = field_vars[f]["box_height"].get() - except tk.TclError: - config[f]["BoxHeight"] = 50 - - save_config(config) - - messagebox.showinfo("Succes", "Settings saved successfully!") - win.destroy() - - tk.Button(win, text="Save All", command=save_all).pack(pady=10) diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 8cad674..0000000 --- a/LICENSE +++ /dev/null @@ -1,8 +0,0 @@ -Copyright (c) 2026 Ascent Group Tech. All Rights Reserved. - -This source code is proprietary and confidential. - -Unauthorized copying, transferring, compilation, -or use of this code, via any medium, -is strictly prohibited without written permission from the authors. - diff --git a/Models/FieldConfig.cs b/Models/FieldConfig.cs index 747a7d4..68814b1 100644 --- a/Models/FieldConfig.cs +++ b/Models/FieldConfig.cs @@ -7,7 +7,10 @@ public class FieldConfig [JsonPropertyName("max_threads")] public double? MaxThreads {get; set; } [JsonPropertyName("psd_layer")] public string PsdLayer {get; set;} - [JsonPropertyName("font_file")] public string FontFile { get; set; } + + // Змінили тут, щоб точно метчилось із JSON та JS + [JsonPropertyName("FontFile")] public string FontFile { get; set; } + [JsonPropertyName("size")] public int Size { get; set; } [JsonPropertyName("color")] public int[] Color { get; set; } [JsonPropertyName("max_chars")] public int MaxChars { get; set; } @@ -18,4 +21,4 @@ public class FieldConfig [JsonPropertyName("BoxWidth")] public int? BoxWidth { get; set; } [JsonPropertyName("BoxHeight")] public int? BoxHeight { get; set; } } -} +} \ No newline at end of file diff --git a/Services/GenerationOptions.cs b/Models/GenerationOptions.cs similarity index 64% rename from Services/GenerationOptions.cs rename to Models/GenerationOptions.cs index a3ed928..e963716 100644 --- a/Services/GenerationOptions.cs +++ b/Models/GenerationOptions.cs @@ -6,10 +6,10 @@ namespace AvifGenerator.Models /// public sealed class GenerationOptions { - public required string CsvPath { get; init; } - public required string ConfigPath { get; init; } - public required string TemplatePath { get; init; } - public required string OutputDir { get; init; } + public string? CsvPath { get; set; } + public string? ConfigPath { get; set; } + public string? TemplatePath { get; set; } + public string? OutputDir { get; set; } public required string BaseDir { get; init; } public required string ReportPath { get; init; } } diff --git a/Program.cs b/Program.cs index 4803cda..b47f644 100644 --- a/Program.cs +++ b/Program.cs @@ -1,183 +1,50 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using System.Text.Json; -using CsvHelper; +using System.Text.Json; +using Photino.NET; using AvifGenerator.Models; using AvifGenerator.Services; using AvifGenerator.Utils; +using Microsoft.Extensions.DependencyInjection; namespace AvifGenerator { class Program { + [STAThread] static int Main(string[] args) { - PrintBanner(); + string baseDir = Helpers.GetProjectRoot(); + string indexPath = Path.Combine(baseDir, "wwwroot", "index.html"); - // ---------------------------------------------------------------- - // 1. Resolve paths - // ---------------------------------------------------------------- - var options = ResolveOptions(args); - if (options is null) return 1; + var services = new ServiceCollection(); - if (!ValidatePaths(options)) return 1; - - Directory.CreateDirectory(options.OutputDir); - - // ---------------------------------------------------------------- - // 2. Load config + CSV - // ---------------------------------------------------------------- - var config = LoadConfig(options.ConfigPath); - if (config is null) return 1; - - var records = LoadCsv(options.CsvPath); - if (records is null || records.Count == 0) + services.AddSingleton(new GenerationOptions { - Console.WriteLine("❌ CSV порожній або не вдалося прочитати."); - return 1; - } - - Console.WriteLine($"📄 Записів у CSV: {records.Count}"); - - // ---------------------------------------------------------------- - // 3. Memory + thread setup - // ---------------------------------------------------------------- - config.TryGetValue("GLOBAL_SETTINGS", out var globalSettings); - - MemoryGuard.Apply(); - int threads = MemoryGuard.CalculateThreadCount((int)globalSettings?.MaxThreads); - int batchSize = MemoryGuard.CalculateBatchSize(threads); - - // ---------------------------------------------------------------- - // 4. Analyze template (once — expensive operation) - // ---------------------------------------------------------------- - Console.WriteLine($"\n⏳ Аналіз шаблону: {Path.GetFileName(options.TemplatePath)}..."); - var (templateBytes, dynamicCoords) = TemplateAnalyzer.Analyze( - options.TemplatePath, config); - - // ---------------------------------------------------------------- - // 5. Run batch generation - // ---------------------------------------------------------------- - Console.WriteLine($"\n🚀 Старт генерації ({records.Count} файлів, " + - $"{threads} потоків, батч {batchSize})..."); - - var processor = new BatchProcessor( - templateBytes, - config, - dynamicCoords, - options.BaseDir, - options.OutputDir, - threads, - batchSize); - - var logs = processor.ProcessAll(records); - - // ---------------------------------------------------------------- - // 6. Report - // ---------------------------------------------------------------- - ReportService.GenerateReport(logs, options.ReportPath); - - int ok = logs.Count(l => l.IsSuccess); - int fail = logs.Count(l => !l.IsSuccess); - Console.WriteLine($"\n✅ Готово: {ok} успішно, {fail} помилок."); - Console.WriteLine($"📝 Звіт: {options.ReportPath}"); - - return fail > 0 ? 2 : 0; // exit code 2 = часткова помилка - } - - // -------------------------------------------------------------------- - // Helpers - // -------------------------------------------------------------------- - - private static GenerationOptions? ResolveOptions(string[] args) - { - string baseDir = AppDomain.CurrentDomain.BaseDirectory; - if (!File.Exists(Path.Combine(baseDir, "config.json"))) - baseDir = Directory.GetCurrentDirectory(); - - string csvPath = args.Length > 0 ? args[0] : Path.Combine(baseDir, "data.csv"); - string configPath = args.Length > 1 ? args[1] : Path.Combine(baseDir, "config.json"); - - string templateDir = Path.GetDirectoryName(configPath) ?? baseDir; - string defaultTemplate = File.Exists(Path.Combine(templateDir, "template.psd")) - ? Path.Combine(templateDir, "template.psd") - : Path.Combine(baseDir, "template.png"); - - string templatePath = args.Length > 2 ? args[2] : defaultTemplate; - string outputDir = args.Length > 3 ? args[3] : Path.Combine(baseDir, "Output_AVIF"); - - return new GenerationOptions - { - CsvPath = csvPath, - ConfigPath = configPath, - TemplatePath = templatePath, - OutputDir = outputDir, + ConfigPath = Path.Combine(baseDir, "config.json"), BaseDir = baseDir, - ReportPath = Path.Combine(baseDir, "report.txt"), - }; - } - - private static bool ValidatePaths(GenerationOptions options) - { - bool ok = true; - void Check(string path, string label) - { - if (!File.Exists(path)) - { - Console.WriteLine($"❌ Не знайдено {label}: {path}"); - ok = false; - } - } - - Check(options.CsvPath, "CSV"); - Check(options.ConfigPath, "Config"); - Check(options.TemplatePath, "Template"); - return ok; - } - - private static Dictionary? LoadConfig(string path) - { - try + ReportPath = Path.Combine(baseDir, "report.txt") + }); + services.AddSingleton(); + services.AddTransient(); + services.AddSingleton(sp => { - string json = File.ReadAllText(path, Encoding.UTF8); - var result = JsonSerializer.Deserialize>(json); - if (result is null) - Console.WriteLine("❌ config.json порожній або невалідний."); - return result; - } - catch (Exception ex) - { - Console.WriteLine($"❌ Помилка читання config.json: {ex.Message}"); - return null; - } - } - - private static List>? LoadCsv(string path) - { - try - { - using var reader = new StreamReader(path, Encoding.UTF8); - using var csv = new CsvHelper.CsvReader(reader, CultureInfo.InvariantCulture); - return csv.GetRecords() - .Select(r => (IDictionary)r) - .ToList(); - } - catch (Exception ex) - { - Console.WriteLine($"❌ Помилка читання CSV: {ex.Message}"); - return null; - } - } - - private static void PrintBanner() - { - Console.WriteLine(new string('=', 50)); - Console.WriteLine(" 🚀 AVIF Generator"); - Console.WriteLine(new string('=', 50)); + var bridge = sp.GetRequiredService(); + var window = new PhotinoWindow() + .SetTitle("AVIF Generator - Visual Editor") + .SetUseOsDefaultSize(false) + .SetSize(1024, 768) + .Center() + .Load(indexPath) + .RegisterWebMessageReceivedHandler(bridge.HandleMessage); + bridge.Window = window; + return window; + }); + + var serviceProvider = services.BuildServiceProvider(); + + var window = serviceProvider.GetRequiredService(); + window.WaitForClose(); + + return 0; } } } diff --git a/Services/Bridge.cs b/Services/Bridge.cs new file mode 100644 index 0000000..5c1fced --- /dev/null +++ b/Services/Bridge.cs @@ -0,0 +1,224 @@ +using System.ComponentModel; +using System.Security.Principal; +using System.Text.Json; +using AvifGenerator.Models; +using AvifGenerator.Services; +using AvifGenerator.Utils; +using CsvHelper; +using NativeFileDialogSharp; +using Photino.NET; + +namespace AvifGenerator.Services +{ + public class Bridge + { + private readonly GenerationOptions _options; + private readonly Executor _executor; + public PhotinoWindow? Window { get; set;} + + public Bridge(GenerationOptions options, Executor executor) + { + _options = options; + _executor = executor; + } + + public void HandleMessage(object? sender, string message) + { + Console.WriteLine($"\n[UI] Message received from Fabric.js!"); + + try + { + using var document = JsonDocument.Parse(message); + var root = document.RootElement; + string action = root.GetProperty("action").GetString() ?? string.Empty; + + switch (action) + { + case "RUN": + HandleRunAction(root); + break; + case "CHOOSE_TEMPLATE": + HandleChooseTemplate(root); + break; + case "CHOOSE_CSV": + HandleChooseCsv(root); + break; + case "CHOOSE_OUTPUT": + HandleChooseOutput(root); + break; + case "LOAD_CONFIG": + HandleLoadConfig(root); + break; + case "CHOOSE_CONFIG": + HandleChooseConfig(root); + break; + case "SAVE_CONFIG": + HandleSaveConfig(root); + break; + default: + Console.WriteLine("Unknown action!"); + break; + } + } + catch (Exception ex) + { + Console.WriteLine($"❌ Failed to process UI message: {ex.Message}"); + } + } + private void HandleRunAction(JsonElement root) + { + HandleSaveConfig(root); + + var freshConfig = ConfigurationLoader.LoadConfig(_options.ConfigPath); + if(freshConfig == null) return; + Console.WriteLine("▶ Settings updated! Starting background generation task..."); + + Window?.SendWebMessage(JsonSerializer.Serialize(new + { + action = "GENERATING", + })); + + _executor.OnMessageReady = (message) => + { + Window?.Invoke(() => + { + Window?.SendWebMessage(message); + }); + }; + Task.Run(() => _executor.ExecuteGeneration(freshConfig)); + } + private void HandleSaveConfig(JsonElement root) + { + try + { + var diskConfig = ConfigurationLoader.LoadConfig(_options.ConfigPath) ?? new(); + + var fields = root.GetProperty("data").GetRawText(); + + var uiConfig = JsonSerializer.Deserialize>(fields); + if(uiConfig != null) + { + foreach (var (key, uiField) in uiConfig) + { + if(key == "GLOBAL_SETTINGS") continue; + if (!diskConfig.ContainsKey(key)) continue; + + diskConfig[key].X = uiField.X ?? diskConfig[key].X; + diskConfig[key].Y = uiField.Y ?? diskConfig[key].Y; + diskConfig[key].BoxWidth = uiField.BoxWidth ?? diskConfig[key].BoxWidth; + diskConfig[key].BoxHeight = uiField.BoxHeight ?? diskConfig[key].BoxHeight; + diskConfig[key].FontFile = uiField.FontFile ?? diskConfig[key].FontFile; + diskConfig[key].Color = uiField.Color ?? diskConfig[key].Color; + } + } + + ConfigurationLoader.SaveConfig(_options.ConfigPath, diskConfig); + + Window?.SendWebMessage(JsonSerializer.Serialize(new + { + action = "CONFIG_SAVED", + success = true + })); + + Console.WriteLine("✅ Config saved from UI panel"); + } + catch (Exception ex) + { + Console.WriteLine($"❌ SaveConfig error: {ex.Message}"); + } + } + private void HandleChooseCsv(JsonElement root) + { + var result = Dialog.FileOpen("csv"); + if(!result.IsOk) return; + + _options.CsvPath = result.Path; + Window?.SendWebMessage(JsonSerializer.Serialize(new + { + action = "CSV_CHOSEN", + name = Path.GetFileName(result.Path), + })); + } + private void HandleChooseTemplate(JsonElement root) + { + var result = Dialog.FileOpen("png"); + if(!result.IsOk) return; + _options.TemplatePath = result.Path; + Window?.SendWebMessage(JsonSerializer.Serialize(new + { + action = "TEMPLATE_CHOSEN", + name = Path.GetFileName(result.Path), + })); + } + private void HandleChooseOutput(JsonElement root) + { + var result = Dialog.FolderPicker(); + if(!result.IsOk) return; + + _options.OutputDir = result.Path; + Window?.SendWebMessage(JsonSerializer.Serialize(new + { + action = "OUTPUT_CHOSEN", + name = Path.GetFileName(result.Path), + })); + } + private void HandleLoadConfig(JsonElement root) + { + if(string.IsNullOrEmpty(_options.ConfigPath) || !File.Exists(_options.ConfigPath)) + { + Window?.SendWebMessage(JsonSerializer.Serialize(new + { + action = "CONFIG_LOADED", + error = "config.json not found", + })); + } + + try + { + var config = ConfigurationLoader.LoadConfig(_options.ConfigPath); + if(config is null) + { + Window?.SendWebMessage(JsonSerializer.Serialize(new + { + action = "CONFIG_LOADED", + error = "Failed to parse config.json", + })); + return; + } + config.Remove("GLOBAL_SETTINGS"); + + + Window?.SendWebMessage(JsonSerializer.Serialize(new + { + action = "CONFIG_LOADED", + config = config, + templatePath = _options.TemplatePath, + })); + + Console.WriteLine($"Sent to JS: {config.Count} fields"); + } + catch(Exception ex) + { + Console.WriteLine($"LoadConfig error: {ex.Message}"); + Window?.SendWebMessage(JsonSerializer.Serialize(new + { + action = "CONFIG_LOADED", + error = ex.Message, + })); + } + } + private void HandleChooseConfig(JsonElement root) + { + var result = Dialog.FileOpen("json"); + if (!result.IsOk) return; + + _options.ConfigPath = result.Path; + Window?.SendWebMessage(JsonSerializer.Serialize(new + { + action = "CONFIG_CHOSEN", + name = Path.GetFileName(result.Path), + path = result.Path, + })); + } + } +} \ No newline at end of file diff --git a/Services/Executor.cs b/Services/Executor.cs new file mode 100644 index 0000000..1753b8c --- /dev/null +++ b/Services/Executor.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using AvifGenerator.Models; +using AvifGenerator.Utils; + +namespace AvifGenerator.Services +{ + public class Executor + { + private readonly GenerationOptions _options; + + public Action? OnMessageReady {get; set;} + + public Executor(GenerationOptions options) + { + _options = options; + } + public void ExecuteGeneration(Dictionary config) + { + try + { + var records = ConfigurationLoader.LoadCsv(_options.CsvPath); + if (records is null || records.Count == 0) + { + Console.WriteLine("❌ CSV порожній або не вдалося прочитати."); + return; + } + + Console.WriteLine($"📄 Записів у CSV: {records.Count}"); + + config.TryGetValue("GLOBAL_SETTINGS", out var globalSettings); + + MemoryGuard.Apply(); + int threads = MemoryGuard.CalculateThreadCount((int?)globalSettings?.MaxThreads); + int batchSize = MemoryGuard.CalculateBatchSize(threads); + + Console.WriteLine($"\n⏳ Аналіз шаблону: {Path.GetFileName(_options.TemplatePath)}..."); + var (templateBytes, dynamicCoords) = TemplateAnalyzer.Analyze(_options.TemplatePath, config); + + Console.WriteLine($"\n🚀 Старт генерації ({records.Count} файлів, {threads} потоків, батч {batchSize})..."); + + var processor = new BatchProcessor( + templateBytes, + config, + dynamicCoords, + _options.BaseDir, + _options.OutputDir, + threads, + batchSize); + + var logs = processor.ProcessAll(records); + + ReportService.GenerateReport(logs, _options.ReportPath); + + int ok = logs.Count(l => l.IsSuccess); + int fail = logs.Count(l => !l.IsSuccess); + Console.WriteLine($"\n✅ Готово: {ok} успішно, {fail} помилок."); + Console.WriteLine($"📝 Звіт: {_options.ReportPath}"); + + OnMessageReady?.Invoke(JsonSerializer.Serialize(new { + action = "GENERATION_DONE", + ok = logs.Count(l => l.IsSuccess), + fail = logs.Count(l => !l.IsSuccess) + })); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Critical Error during generation: {ex.Message}"); + } + } + } +} \ No newline at end of file diff --git a/Services/ImageBuilder.cs b/Services/ImageBuilder.cs index 1777701..3d783f7 100644 --- a/Services/ImageBuilder.cs +++ b/Services/ImageBuilder.cs @@ -100,7 +100,8 @@ private void DrawTextField( (byte)fieldCfg.Color[1], (byte)fieldCfg.Color[2]); - string fontPath = Path.Combine(_baseDir, fieldCfg.FontFile); + string fontPath = fieldCfg.FontFile ?? "arial.ttf"; + fontPath = Path.Combine(_baseDir, fontPath); if (!File.Exists(fontPath)) fontPath = "Arial"; Gravity gravity = fieldCfg.Align?.ToLower() switch diff --git a/Utils/ConfigurationLoader.cs b/Utils/ConfigurationLoader.cs new file mode 100644 index 0000000..ced8ce7 --- /dev/null +++ b/Utils/ConfigurationLoader.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using AvifGenerator.Models; + +namespace AvifGenerator.Utils +{ + public static class ConfigurationLoader + { + public static Dictionary? LoadConfig(string path) + { + // Your exact existing code goes here... + try + { + string json = File.ReadAllText(path, Encoding.UTF8); + var result = JsonSerializer.Deserialize>(json); + if (result is null) + Console.WriteLine("❌ config.json порожній або невалідний."); + return result; + } + catch (Exception ex) + { + Console.WriteLine($"❌ Помилка читання config.json: {ex.Message}"); + return null; + } + } + + public static List>? LoadCsv(string path) + { + try + { + using var reader = new StreamReader(path, Encoding.UTF8); + using var csv = new CsvHelper.CsvReader(reader, CultureInfo.InvariantCulture); + return csv.GetRecords() + .Select(r => (IDictionary)r) + .ToList(); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Помилка читання CSV: {ex.Message}"); + return null; + } + } + public static void SaveConfig(string path, Dictionary config) + { + try + { + // WriteIndented makes the JSON pretty and readable + var jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + string jsonString = JsonSerializer.Serialize(config, jsonOptions); + + File.WriteAllText(path, jsonString, Encoding.UTF8); + Console.WriteLine("💾 config.json successfully overwritten with new UI coordinates."); + } + catch (Exception ex) + { + Console.WriteLine($"❌ Failed to save config.json: {ex.Message}"); + } + } + } +} diff --git a/Utils/Helpers.cs b/Utils/Helpers.cs new file mode 100644 index 0000000..2c1bf9d --- /dev/null +++ b/Utils/Helpers.cs @@ -0,0 +1,46 @@ +using AvifGenerator.Models; + +namespace AvifGenerator.Utils +{ + public static class Helpers + { + + public static bool ValidatePaths(GenerationOptions options) + { + bool ok = true; + void Check(string path, string label) + { + if (!File.Exists(path)) + { + Console.WriteLine($"❌ Не знайдено {label}: {path}"); + ok = false; + } + } + + Check(options.CsvPath, "CSV"); + Check(options.ConfigPath, "Config"); + Check(options.TemplatePath, "Template"); + return ok; + } + + public static string GetProjectRoot() + { + string currentDir = AppDomain.CurrentDomain.BaseDirectory; + + DirectoryInfo? dirInfo = new DirectoryInfo(currentDir); + + while (dirInfo != null) + { + if (Directory.Exists(Path.Combine(dirInfo.FullName, "wwwroot")) || + File.Exists(Path.Combine(dirInfo.FullName, "config.json"))) + { + return dirInfo.FullName; + } + + dirInfo = dirInfo.Parent; + } + + return AppDomain.CurrentDomain.BaseDirectory; + } + } +} \ No newline at end of file diff --git a/Utils/TextHelper.cs b/Utils/TextHelper.cs deleted file mode 100644 index 00cda18..0000000 --- a/Utils/TextHelper.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace AvifGenerator.Utils -{ - public static class TextHelper - { - public static string WrapText(string text, int maxChars) - { - if(string.IsNullOrWhiteSpace(text)) return text; - - var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries); - var lines = new List(); - - string currentLine = ""; - - foreach(var word in words) - { - if ((currentLine + word).Length > maxChars) - { - if (currentLine.Length > 0) lines.Add(currentLine.Trim()); - currentLine = word + " "; - } - else currentLine += word + " "; - } - if(currentLine.Length > 0) lines.Add(currentLine.Trim()); - return string.Join("\n",lines); - } - } -} diff --git a/config.json b/config.json index 98f1c5f..c785f7f 100644 --- a/config.json +++ b/config.json @@ -1,103 +1,138 @@ -{ - "GLOBAL_SETTINGS": { - "max_threads": 0.5 - }, - "EMAIL": { - "x": 430, - "y": 965, - "psd_layer": "mai", - "align": "center", - "font_file": "arialbd.ttf", - "size": 48, - "color": [ - 255, - 255, - 255 - ], - "max_chars": 30 - }, - "STRONA_WWW": { - "x": 370, - "y": 815, - "psd_layer": "www", - "align": "center", - "font_file": "arial.ttf", - "size": 48, - "color": [ - 255, - 255, - 255 - ], - "max_chars": 40 - }, - "TELEFON": { - "x": 1260, - "y": 960, - "psd_layer": "tel", - "align": "center", - "font_file": "arial.ttf", - "size": 48, - "color": [ - 255, - 255, - 255 - ], - "max_chars": 30 - }, - "NAZWA": { - "x": 960, - "y": 80, - "psd_layer": "naz", - "align": "center", - "font_file": "arial.ttf", - "size": 45, - "color": [ - 255, - 255, - 255 - ], - "max_chars": 40 - }, - "NAGLOWEK": { - "x": 972, - "y": 332, - "psd_layer": "Nag", - "align": "center", - "font_file": "arialbd.ttf", - "size": 72, - "color": [ - 0, - 0, - 0 - ], - "max_chars": 40 - }, - "OPIS": { - "x": 950, - "y": 410, - "psd_layer": "opi", - "align": "center", - "font_file": "arialbd.ttf", - "size": 47, - "color": [ - 121, - 121, - 121 - ], - "max_chars": 40 - }, - "FACEBOOK": { - "x": 1150, - "y": 815, - "psd_layer": "fac", - "align": "left", - "font_file": "arialbd.ttf", - "size": 48, - "color": [ - 255, - 255, - 255 - ], - "max_chars": 40 - } -} +{ + "GLOBAL_SETTINGS": { + "max_threads": 1, + "psd_layer": null, + "FontFile": "arial.ttf", + "size": 12, + "color": [ + 255, + 255, + 255 + ], + "max_chars": 0, + "align": "left", + "x": 0, + "y": 0, + "BoxWidth": 100, + "BoxHeight": 50 + }, + "EMAIL": { + "max_threads": null, + "psd_layer": "mai", + "FontFile": "arial.ttf", + "size": 30, + "color": [ + 255, + 255, + 255 + ], + "max_chars": 0, + "align": "right", + "x": 175, + "y": 793, + "BoxWidth": 650, + "BoxHeight": 40 + }, + "STRONA_WWW": { + "max_threads": null, + "psd_layer": "www", + "FontFile": "arial.ttf", + "size": 30, + "color": [ + 255, + 255, + 255 + ], + "max_chars": 0, + "align": "right", + "x": 175, + "y": 698, + "BoxWidth": 650, + "BoxHeight": 40 + }, + "TELEFON": { + "max_threads": null, + "psd_layer": "tel", + "FontFile": "arial.ttf", + "size": 30, + "color": [ + 255, + 255, + 255 + ], + "max_chars": 0, + "align": "right", + "x": 1130, + "y": 790, + "BoxWidth": 650, + "BoxHeight": 40 + }, + "NAZWA": { + "max_threads": null, + "psd_layer": "naz", + "FontFile": "arial.ttf", + "size": 36, + "color": [ + 255, + 255, + 255 + ], + "max_chars": 0, + "align": "center", + "x": 454, + "y": 190, + "BoxWidth": 790, + "BoxHeight": 40 + }, + "NAGLOWEK": { + "max_threads": null, + "psd_layer": "Nag", + "FontFile": "arial.ttf", + "size": 72, + "color": [ + 255, + 255, + 255 + ], + "max_chars": 0, + "align": "center", + "x": 280, + "y": 300, + "BoxWidth": 1200, + "BoxHeight": 72 + }, + "OPIS": { + "max_threads": null, + "psd_layer": "opi", + "FontFile": "arial.ttf", + "size": 40, + "color": [ + 255, + 255, + 255 + ], + "max_chars": 0, + "align": "center", + "x": 400, + "y": 398, + "BoxWidth": 900, + "BoxHeight": 53 + }, + "FACEBOOK": { + "max_threads": null, + "psd_layer": "fac", + "FontFile": "arial.ttf", + "size": 30, + "color": [ + 255, + 255, + 255 + ], + "max_chars": 0, + "align": "right", + "x": 1110, + "y": 698, + "BoxWidth": 650, + "BoxHeight": 40 + } +} \ No newline at end of file diff --git a/dockerfile b/dockerfile deleted file mode 100644 index b2566af..0000000 --- a/dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build -WORKDIR /src -COPY *.csproj ./ -RUN dotnet restore -COPY . ./ -# RUN dotnet publish -c Release -o /out -# RUN dotnet publish -c Release -r linux-x64 --self-contained true -o /out -RUN dotnet publish -c Release -r linux-x64 --self-contained true -p:PublishTrimmed=true -p:PublishSingleFile=true -o /out - -FROM python:3.14-slim -RUN apt-get update && \ - apt-get install -y python3-tk && \ - rm -rf /var/lib/apt/lists/* -WORKDIR /app -COPY GUI/ ./ -COPY --from=build /out ./ -RUN chmod +x AvifGenerator -RUN python3 -m pip install --no-cache-dir --break-system-packages -r requirements.txt - -ENTRYPOINT ["python3", "main.py"] diff --git a/wwwroot/bridge.js b/wwwroot/bridge.js new file mode 100644 index 0000000..159acc9 --- /dev/null +++ b/wwwroot/bridge.js @@ -0,0 +1,175 @@ +/** + * bridge.js + * Єдина точка комунікації між Fabric.js UI та C# Bridge. + * Всі повідомлення йдуть через window.external.sendMessage (Photino API). + */ + +// ── Стан файлів ────────────────────────────────────────────── +const fileState = { + csv: false, + template: false, + config: false, + output: false, +}; + +// ── Відправка в C# ─────────────────────────────────────────── +function send(action, data = {}) { + const message = JSON.stringify({ action, data }); + window.external.sendMessage(message); +} + +function sendAction(action, data = {}) { + send(action, data); +} + +// ── Прийом з C# ───────────────────────────────────────────── +window.external.receiveMessage(rawMessage => { + let msg; + try { + msg = JSON.parse(rawMessage); + } catch (e) { + console.error('[Bridge] Failed to parse message:', rawMessage); + return; + } + + console.log('[Bridge] Received:', msg.action); + + const handler = handlers[msg.action]; + if (handler) { + handler(msg); + } else { + console.warn('[Bridge] No handler for action:', msg.action); + } +}); + +// ── Handlers — відповіді від C# ───────────────────────────── +const handlers = { + + CSV_CHOSEN(msg) { + markSlotDone('slot-csv', 'csv-name', 'csv-status', msg.name); + fileState.csv = true; + checkAllChosen(); + }, + + TEMPLATE_CHOSEN(msg) { + markSlotDone('slot-template', 'template-name', 'template-status', msg.name); + fileState.template = true; + checkAllChosen(); + // Якщо редактор вже відкритий — оновити фон canvas + if (window.editorReady) { + loadTemplateBackground(msg.path); + } + }, + + CONFIG_CHOSEN(msg) { + markSlotDone('slot-config', 'config-name', 'config-status', msg.name); + fileState.config = true; + checkAllChosen(); + }, + + OUTPUT_CHOSEN(msg) { + markSlotDone('slot-output', 'output-name', 'output-status', msg.path || msg.name); + fileState.output = true; + checkAllChosen(); + }, + + CONFIG_LOADED(msg) { + if (msg.error) { + showToast('❌ ' + msg.error, 'error'); + return; + } + // Передаємо в editor.js + if (window.onConfigLoaded) { + window.onConfigLoaded(msg.config, msg.templatePath); + } + }, + + CONFIG_SAVED(msg) { + if (msg.success) { + showToast('✅ Config saved'); + } + }, + + GENERATION_DONE(msg) { + showToast(`✅ Done! ${msg.ok} generated, ${msg.fail} errors`); + document.querySelector('.btn-primary').disabled = false; + document.querySelector('.btn-primary').textContent = '▶ Run'; + }, + + GENERATION_ERROR(msg) { + showToast('❌ ' + (msg.message || 'Generation failed'), 'error'); + document.querySelector('.btn-primary').disabled = false; + }, + + GENERATION_PROGRESS(msg) { + showToast(`⏳ ${msg.current}/${msg.total} — ${msg.filename}`); + }, + GENERATION_DONE(msg) { + showToast(`✅ Done! ${msg.ok} generated, ${msg.fail} errors`); + document.querySelector('.btn-primary').disabled = false; + document.querySelector('.btn-primary').innerHTML = ` + + + Run`; + }, +}; + +// ── UI helpers ─────────────────────────────────────────────── +function markSlotDone(slotId, nameId, statusId, value) { + const slot = document.getElementById(slotId); + const name = document.getElementById(nameId); + if (slot) slot.classList.add('done'); + if (name) { + // Показуємо тільки назву файлу, не повний шлях + name.textContent = value.split(/[\\/]/).pop() || value; + name.title = value; + } +} + +function checkAllChosen() { + const allDone = fileState.csv && fileState.template && + fileState.config && fileState.output; + const btn = document.getElementById('open-editor-btn'); + if (btn) btn.disabled = !allDone; +} + +// ── Toast ──────────────────────────────────────────────────── +let toastTimer = null; + +function showToast(message, type = 'info') { + const toast = document.getElementById('toast'); + if (!toast) return; + toast.textContent = message; + toast.className = 'toast show'; + if (type === 'error') toast.style.borderColor = 'var(--err)'; + else toast.style.borderColor = 'var(--border2)'; + + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + toast.className = 'toast'; + }, 3000); +} + +// ── Перехід до редактора ───────────────────────────────────── +function openEditor() { + document.getElementById('setup-screen').classList.remove('active'); + document.getElementById('editor-screen').classList.add('active'); + // Запит конфігу з C# + send('LOAD_CONFIG'); +} + +function backToSetup() { + document.getElementById('editor-screen').classList.remove('active'); + document.getElementById('setup-screen').classList.add('active'); +} + +// ── Запуск генерації ───────────────────────────────────────── +function runGeneration() { + const btn = document.querySelector('.btn-primary'); + btn.disabled = true; + btn.textContent = '⏳ Running...'; + + // Збираємо актуальні позиції з canvas + дані правої панелі + const payload = collectCanvasData(); + send('RUN', payload); +} \ No newline at end of file diff --git a/wwwroot/editor.js b/wwwroot/editor.js new file mode 100644 index 0000000..5040900 --- /dev/null +++ b/wwwroot/editor.js @@ -0,0 +1,349 @@ +/** + * editor.js + * Fabric.js canvas — відмальовує поля з конфігу на шаблоні, + * синхронізує з правою панеллю, дає drag/resize як у Photoshop. + */ + +// ── Стан ──────────────────────────────────────────────────── +let canvas = null; // fabric.Canvas +let configData = {}; // { FIELDNAME: { x, y, BoxWidth, BoxHeight, color, FontFile } } +let selectedKey = null; // поточно вибране поле +let fieldObjects = {}; // { FIELDNAME: { rect: fabric.Rect, label: fabric.Text } } + +// Палітра кольорів для підсвітки рамок полів +const PALETTE = [ + '#e8ff47', '#47e8ff', '#ff47e8', '#ff8c47', + '#47ff8c', '#8c47ff', '#ff4747', '#47a0ff', +]; + +// ── Ініціалізація Fabric.js ────────────────────────────────── +function initCanvas(width = 800, height = 600) { + if (canvas) { + canvas.dispose(); + canvas = null; + } + + // Розмір canvas під шаблон + const el = document.getElementById('fabric-canvas'); + el.width = width; + el.height = height; + + canvas = new fabric.Canvas('fabric-canvas', { + selection: false, // не дозволяємо multi-select + preserveObjectStacking: true, + }); + + canvas.on('object:modified', onObjectModified); + canvas.on('selection:created', onSelectionChange); + canvas.on('selection:updated', onSelectionChange); + canvas.on('selection:cleared', onSelectionCleared); + + window.editorReady = true; +} + +// ── Завантаження шаблону як фон ────────────────────────────── +function loadTemplateBackground(templatePath) { + if (!canvas) return; + + // Photino передає локальний шлях — використовуємо як є + fabric.Image.fromURL(templatePath, img => { + // Підганяємо canvas під розміри шаблону + canvas.setWidth(img.width); + canvas.setHeight(img.height); + + canvas.setBackgroundImage(img, canvas.renderAll.bind(canvas), { + originX: 'left', + originY: 'top', + }); + + // Оновлюємо розміри обгортки + const wrap = document.getElementById('canvas-wrap'); + if (wrap) { + document.querySelector('.canvas-container-inner').style.width = img.width + 'px'; + document.querySelector('.canvas-container-inner').style.height = img.height + 'px'; + } + }, { crossOrigin: 'anonymous' }); +} + +// ── Callback з bridge.js — отримали конфіг з C# ───────────── +window.onConfigLoaded = function(config, templatePath) { + configData = config; + + // Ініціалізуємо canvas під розмір — потім фон підтягнеться + initCanvas(800, 600); + + if (templatePath) { + loadTemplateBackground(templatePath); + } + + renderFields(); + buildFieldsList(); +}; + +// ── Відмальовуємо всі поля з конфігу ──────────────────────── +function renderFields() { + if (!canvas) return; + + // Прибираємо старі об'єкти (крім фону) + canvas.getObjects().forEach(obj => canvas.remove(obj)); + fieldObjects = {}; + + let colorIdx = 0; + + Object.entries(configData).forEach(([key, cfg]) => { + const color = PALETTE[colorIdx++ % PALETTE.length]; + const x = cfg.x ?? 50; + const y = cfg.y ?? 50; + const width = cfg.BoxWidth ?? 250; + const height = cfg.BoxHeight ?? 60; + + // Рамка поля + const rect = new fabric.Rect({ + left: x, + top: y, + width: width, + height: height, + fill: 'rgba(0,0,0,0)', + stroke: color, + strokeWidth: 2, + strokeDashArray: [6, 3], + hasRotatingPoint: false, + lockRotation: true, + cornerSize: 8, + cornerColor: color, + cornerStyle: 'circle', + transparentCorners: false, + fieldKey: key, // кастомний атрибут + }); + + // Підпис назви поля + const label = new fabric.Text(key, { + left: x + 6, + top: y + 4, + fontSize: 11, + fontFamily: 'DM Mono, monospace', + fill: color, + selectable: false, + evented: false, + fieldKey: key, + isLabel: true, + }); + + canvas.add(rect); + canvas.add(label); + + fieldObjects[key] = { rect, label }; + }); + + canvas.renderAll(); +} + +// ── Права панель — список полів ────────────────────────────── +function buildFieldsList() { + const list = document.getElementById('fields-list'); + list.innerHTML = ''; + + let colorIdx = 0; + + Object.entries(configData).forEach(([key, cfg]) => { + const color = PALETTE[colorIdx++ % PALETTE.length]; + + const chip = document.createElement('div'); + chip.className = 'field-chip'; + chip.dataset.key = key; + chip.innerHTML = ` +
+ ${key} + ${cfg.x ?? 0},${cfg.y ?? 0} + `; + chip.addEventListener('click', () => selectField(key)); + list.appendChild(chip); + }); +} + +// ── Вибір поля ─────────────────────────────────────────────── +function selectField(key) { + selectedKey = key; + + // Підсвітити chip + document.querySelectorAll('.field-chip').forEach(c => { + c.classList.toggle('active', c.dataset.key === key); + }); + + // Виділити об'єкт на canvas + const obj = fieldObjects[key]; + if (obj) { + canvas.setActiveObject(obj.rect); + canvas.renderAll(); + } + + // Заповнити праву панель + fillPanel(key); +} + +function fillPanel(key) { + const cfg = configData[key]; + if (!cfg) return; + + document.getElementById('field-editor').style.display = 'flex'; + document.getElementById('field-editor-title').textContent = key; + + document.getElementById('inp-x').value = cfg.x ?? 0; + document.getElementById('inp-y').value = cfg.y ?? 0; + document.getElementById('inp-w').value = cfg.BoxWidth ?? 250; + document.getElementById('inp-h').value = cfg.BoxHeight ?? 60; + document.getElementById('inp-font').value = cfg.FontFile ?? 'arial.ttf'; + + // Color: масив [R,G,B] → hex + const hex = rgbArrayToHex(cfg.color ?? [0, 0, 0]); + document.getElementById('inp-color-hex').value = hex; + document.getElementById('inp-color-picker').value = hex; +} + +// ── Fabric selection callbacks ─────────────────────────────── +function onSelectionChange(e) { + const active = canvas.getActiveObject(); + if (!active || !active.fieldKey) return; + const key = active.fieldKey; + if (key !== selectedKey) { + selectedKey = key; + document.querySelectorAll('.field-chip').forEach(c => { + c.classList.toggle('active', c.dataset.key === key); + }); + fillPanel(key); + } +} + +function onSelectionCleared() { + // Не ховаємо панель — зручніше залишити останнє вибране +} + +// ── Fabric object:modified — оновити конфіг і панель ──────── +function onObjectModified(e) { + const obj = e.target; + if (!obj || !obj.fieldKey) return; + + const key = obj.fieldKey; + const cfg = configData[key]; + if (!cfg) return; + + // Fabric зберігає scaleX/scaleY при resize — нормалізуємо + const newW = Math.round(obj.width * (obj.scaleX ?? 1)); + const newH = Math.round(obj.height * (obj.scaleY ?? 1)); + const newX = Math.round(obj.left); + const newY = Math.round(obj.top); + + cfg.x = newX; + cfg.y = newY; + cfg.BoxWidth = newW; + cfg.BoxHeight = newH; + + // Скидаємо scale — інакше наступний resize накопичує + obj.set({ scaleX: 1, scaleY: 1, width: newW, height: newH }); + + // Пересуваємо label разом з rect + const labelObj = fieldObjects[key]?.label; + if (labelObj) { + labelObj.set({ left: newX + 6, top: newY + 4 }); + } + + canvas.renderAll(); + + // Оновити chip позицію + const chipPos = document.getElementById(`chip-pos-${key}`); + if (chipPos) chipPos.textContent = `${newX},${newY}`; + + // Оновити поля панелі якщо це вибране поле + if (key === selectedKey) { + document.getElementById('inp-x').value = newX; + document.getElementById('inp-y').value = newY; + document.getElementById('inp-w').value = newW; + document.getElementById('inp-h').value = newH; + } +} + +// ── Права панель → canvas (ручне введення координат) ──────── +function updateSelectedFromPanel() { + if (!selectedKey) return; + const cfg = configData[selectedKey]; + if (!cfg) return; + + const x = parseInt(document.getElementById('inp-x').value) || 0; + const y = parseInt(document.getElementById('inp-y').value) || 0; + const w = parseInt(document.getElementById('inp-w').value) || 50; + const h = parseInt(document.getElementById('inp-h').value) || 30; + + cfg.x = x; + cfg.y = y; + cfg.BoxWidth = w; + cfg.BoxHeight = h; + + const objs = fieldObjects[selectedKey]; + if (!objs) return; + + objs.rect.set({ left: x, top: y, width: w, height: h, scaleX: 1, scaleY: 1 }); + objs.label.set({ left: x + 6, top: y + 4 }); + canvas.renderAll(); + + const chipPos = document.getElementById(`chip-pos-${selectedKey}`); + if (chipPos) chipPos.textContent = `${x},${y}`; +} + +// ── Колір: picker ↔ hex input ──────────────────────────────── +function syncColorFromPicker() { + const hex = document.getElementById('inp-color-picker').value; + document.getElementById('inp-color-hex').value = hex; + applyColor(hex); +} + +function syncColorFromHex() { + const hex = document.getElementById('inp-color-hex').value; + if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return; + document.getElementById('inp-color-picker').value = hex; + applyColor(hex); +} + +function applyColor(hex) { + if (!selectedKey) return; + const cfg = configData[selectedKey]; + if (!cfg) return; + cfg.color = hexToRgbArray(hex); +} + +// ── Зібрати дані панелі для збереження ────────────────────── +function collectPanelData() { + // Якщо вибране поле — зберігаємо FontFile з інпуту (може бути змінений вручну) + if (selectedKey && configData[selectedKey]) { + configData[selectedKey].FontFile = + document.getElementById('inp-font').value || 'arial.ttf'; + } + + return configData; +} + +// ── Зібрати позиції з canvas для RUN ──────────────────────── +function collectCanvasData() { + // Синхронізуємо FontFile перед відправкою + if (selectedKey && configData[selectedKey]) { + configData[selectedKey].FontFile = + document.getElementById('inp-font').value || 'arial.ttf'; + } + + return configData; +} + +// ── Утиліти ────────────────────────────────────────────────── +function rgbArrayToHex(arr) { + if (!Array.isArray(arr) || arr.length < 3) return '#000000'; + return '#' + arr.map(v => { + const h = Math.max(0, Math.min(255, v)).toString(16); + return h.length === 1 ? '0' + h : h; + }).join(''); +} + +function hexToRgbArray(hex) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return [r, g, b]; +} \ No newline at end of file diff --git a/wwwroot/index.html b/wwwroot/index.html new file mode 100644 index 0000000..e49dd68 --- /dev/null +++ b/wwwroot/index.html @@ -0,0 +1,154 @@ + + + + + + AVIF Generator + + + + + + + + +
+
+ +
+
+
AV
+
+

AVIF Generator

+

Visual Banner Editor

+
+
+ +
+
+
CSV
+
+ Data Source + No file chosen +
+
+
+ +
+
PNG
+
+ Template + No file chosen +
+
+
+ +
+
CFG
+
+ Config JSON + No file chosen +
+
+
+ +
+
OUT
+
+ Export Folder + No folder chosen +
+
+
+
+ + +
+
+ + +
+ + +
+ + Visual Editor +
+ + +
+
+ +
+ + +
+
+ +
+
+ + + +
+
+ + +
+ + + + + diff --git a/wwwroot/style.css b/wwwroot/style.css new file mode 100644 index 0000000..73a3f01 --- /dev/null +++ b/wwwroot/style.css @@ -0,0 +1,455 @@ +/* ═══════════════════════════════════════════ + AVIF Generator — Dark Industrial UI + Syne (display) + DM Mono (data) +═══════════════════════════════════════════ */ + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0e0e0f; + --surface: #161618; + --surface2: #1e1e21; + --border: #2a2a2e; + --border2: #3a3a40; + --accent: #e8ff47; + --accent-dim: #b8cc2a; + --text: #f0f0f0; + --text-2: #888890; + --text-3: #555560; + --ok: #4ade80; + --err: #f87171; + --radius: 6px; + --font-ui: 'Syne', sans-serif; + --font-mono: 'DM Mono', monospace; +} + +html, body { + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--font-ui); + overflow: hidden; + user-select: none; +} + +/* ── Screens ── */ +.screen { display: none; width: 100vw; height: 100vh; } +.screen.active { display: flex; } + +/* ════════════════════════════════════════ + SETUP SCREEN +════════════════════════════════════════ */ +#setup-screen { + position: relative; + align-items: center; + justify-content: center; + flex-direction: column; +} + +.setup-bg { + position: absolute; inset: 0; + background: + radial-gradient(ellipse 60% 50% at 70% 20%, rgba(232,255,71,.06) 0%, transparent 70%), + repeating-linear-gradient(0deg, transparent, transparent 39px, var(--border) 40px), + repeating-linear-gradient(90deg, transparent, transparent 39px, var(--border) 40px); + pointer-events: none; +} + +.setup-content { + position: relative; + z-index: 1; + width: 480px; + display: flex; + flex-direction: column; + gap: 32px; +} + +.setup-header { + display: flex; + align-items: center; + gap: 16px; +} + +.logo-mark { + width: 52px; height: 52px; + background: var(--accent); + color: var(--bg); + font-family: var(--font-ui); + font-weight: 800; + font-size: 18px; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; +} + +.setup-title { + font-size: 24px; + font-weight: 800; + letter-spacing: -0.5px; +} + +.setup-sub { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-2); + margin-top: 2px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* File slots */ +.file-slots { display: flex; flex-direction: column; gap: 8px; } + +.file-slot { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + background: var(--surface); + border: 1px solid var(--border); + cursor: pointer; + transition: border-color .15s, background .15s; + position: relative; + overflow: hidden; +} + +.file-slot::before { + content: ''; + position: absolute; + left: 0; top: 0; bottom: 0; + width: 3px; + background: var(--border2); + transition: background .15s; +} + +.file-slot:hover { border-color: var(--border2); background: var(--surface2); } +.file-slot:hover::before { background: var(--accent); } +.file-slot.done::before { background: var(--ok); } +.file-slot.done { border-color: rgba(74,222,128,.2); } + +.slot-icon { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + color: var(--text-3); + letter-spacing: .08em; + width: 32px; + text-align: center; + flex-shrink: 0; +} + +.slot-info { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +.slot-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--text-2); + font-weight: 600; +} + +.slot-value { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.slot-status { + width: 8px; height: 8px; + border-radius: 50%; + background: var(--border2); + flex-shrink: 0; + transition: background .2s; +} +.file-slot.done .slot-status { background: var(--ok); } + +/* Open Editor button */ +.open-editor-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + padding: 15px; + background: var(--accent); + color: var(--bg); + border: none; + font-family: var(--font-ui); + font-size: 14px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + cursor: pointer; + transition: background .15s, transform .1s; +} + +.open-editor-btn:hover:not(:disabled) { background: var(--accent-dim); } +.open-editor-btn:active:not(:disabled) { transform: scale(.99); } +.open-editor-btn:disabled { + background: var(--surface2); + color: var(--text-3); + cursor: not-allowed; +} + +/* ════════════════════════════════════════ + EDITOR SCREEN +════════════════════════════════════════ */ +#editor-screen { flex-direction: column; } + +/* Topbar */ +.topbar { + display: flex; + align-items: center; + gap: 16px; + padding: 0 16px; + height: 48px; + background: var(--surface); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.topbar-back { + display: flex; align-items: center; gap: 6px; + background: none; border: none; + color: var(--text-2); + font-family: var(--font-ui); + font-size: 13px; + cursor: pointer; + padding: 4px 8px; + transition: color .15s; +} +.topbar-back:hover { color: var(--text); } + +.topbar-title { + font-size: 13px; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; + flex: 1; + text-align: center; + color: var(--text-2); +} + +.topbar-actions { display: flex; gap: 8px; } + +.btn-primary { + display: flex; align-items: center; gap: 6px; + padding: 6px 14px; + background: var(--accent); + color: var(--bg); + border: none; + font-family: var(--font-ui); + font-size: 12px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + cursor: pointer; + transition: background .15s; +} +.btn-primary:hover { background: var(--accent-dim); } + +.btn-secondary { + padding: 6px 14px; + background: transparent; + color: var(--text-2); + border: 1px solid var(--border2); + font-family: var(--font-ui); + font-size: 12px; + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; + cursor: pointer; + transition: border-color .15s, color .15s; +} +.btn-secondary:hover { border-color: var(--text-2); color: var(--text); } + +/* Editor layout */ +.editor-layout { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Canvas area */ +.canvas-wrap { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; + background: + radial-gradient(ellipse 80% 80% at 50% 50%, rgba(232,255,71,.02) 0%, transparent 70%), + repeating-linear-gradient(0deg, transparent, transparent 23px, var(--surface) 24px), + repeating-linear-gradient(90deg, transparent, transparent 23px, var(--surface) 24px); + background-color: var(--bg); + padding: 32px; +} + +.canvas-container-inner { + box-shadow: 0 0 0 1px var(--border), 0 24px 80px rgba(0,0,0,.6); + position: relative; +} + +#fabric-canvas { display: block; } + +/* Right panel */ +.right-panel { + width: 260px; + flex-shrink: 0; + background: var(--surface); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .1em; +} + +.panel-hint { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-3); + font-weight: 400; + text-transform: none; + letter-spacing: 0; +} + +.fields-list { + display: flex; + flex-direction: column; + border-bottom: 1px solid var(--border); + overflow-y: auto; + max-height: 220px; +} + +.field-chip { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + cursor: pointer; + border-left: 3px solid transparent; + transition: background .12s, border-color .12s; + font-size: 12px; +} + +.field-chip:hover { background: var(--surface2); } +.field-chip.active { + background: var(--surface2); + border-left-color: var(--accent); +} + +.field-chip-dot { + width: 8px; height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.field-chip-name { + font-weight: 600; + font-size: 12px; + flex: 1; +} + +.field-chip-pos { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-3); +} + +/* Field editor panel */ +.panel-section { + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; + flex: 1; +} + +.panel-section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .1em; + color: var(--accent); + margin-bottom: 4px; +} + +.field-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--text-3); + font-weight: 600; + margin-top: 4px; +} + +.field-input { + width: 100%; + padding: 7px 10px; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + font-family: var(--font-mono); + font-size: 12px; + outline: none; + transition: border-color .15s; +} +.field-input:focus { border-color: var(--accent); } + +.color-row { display: flex; gap: 8px; align-items: center; } +.color-hex { flex: 1; } + +.color-picker { + width: 36px; height: 32px; + border: 1px solid var(--border); + background: none; + cursor: pointer; + padding: 2px; +} + +/* ── Toast ── */ +.toast { + position: fixed; + bottom: 24px; left: 50%; + transform: translateX(-50%) translateY(20px); + background: var(--surface2); + border: 1px solid var(--border2); + color: var(--text); + font-family: var(--font-mono); + font-size: 12px; + padding: 10px 20px; + opacity: 0; + transition: opacity .2s, transform .2s; + pointer-events: none; + white-space: nowrap; + z-index: 999; +} +.toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +/* Scrollbars */ +::-webkit-scrollbar { width: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }